spree_cm_commissioner 2.14.7 → 2.14.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/app/controllers/concerns/spree_cm_commissioner/content_cachable.rb +9 -1
  4. data/app/controllers/concerns/spree_cm_commissioner/guest_owner_authorization.rb +30 -0
  5. data/app/controllers/spree/admin/stock_managements_controller.rb +12 -1
  6. data/app/controllers/spree/api/v2/organizer/invites_controller.rb +5 -1
  7. data/app/controllers/spree/api/v2/storefront/guests_controller.rb +13 -7
  8. data/app/controllers/spree/api/v2/storefront/id_cards_controller.rb +6 -4
  9. data/app/controllers/spree/api/v2/storefront/products_controller_decorator.rb +1 -1
  10. data/app/controllers/spree/api/v2/storefront/s3_signed_urls_controller.rb +4 -3
  11. data/app/controllers/spree/api/v2/tenant/base_controller.rb +23 -3
  12. data/app/controllers/spree/api/v2/tenant/cart_controller.rb +1 -1
  13. data/app/controllers/spree/api/v2/tenant/id_cards_controller.rb +6 -4
  14. data/app/controllers/spree/api/v2/tenant/payment_methods_controller.rb +1 -2
  15. data/app/controllers/spree/api/v2/tenant/products_controller.rb +1 -1
  16. data/app/controllers/spree/api/v2/tenant/s3_signed_urls_controller.rb +4 -3
  17. data/app/interactors/spree_cm_commissioner/guest_id_card_manager.rb +18 -9
  18. data/app/interactors/spree_cm_commissioner/id_card_image_updater.rb +6 -3
  19. data/app/interactors/spree_cm_commissioner/image_saver.rb +4 -3
  20. data/app/interactors/spree_cm_commissioner/profile_image_updater.rb +6 -3
  21. data/app/models/concerns/spree_cm_commissioner/order_state_machine.rb +1 -9
  22. data/app/models/concerns/spree_cm_commissioner/sales_channel_bitwise.rb +44 -0
  23. data/app/models/concerns/spree_cm_commissioner/user_uploaded_image.rb +17 -0
  24. data/app/models/spree_cm_commissioner/back_image.rb +2 -0
  25. data/app/models/spree_cm_commissioner/front_image.rb +2 -0
  26. data/app/models/spree_cm_commissioner/order_decorator.rb +11 -0
  27. data/app/models/spree_cm_commissioner/product_decorator.rb +4 -0
  28. data/app/models/spree_cm_commissioner/s3_presigned_url.rb +8 -7
  29. data/app/models/spree_cm_commissioner/s3_presigned_url_builder.rb +9 -3
  30. data/app/models/spree_cm_commissioner/tenant.rb +3 -0
  31. data/app/models/spree_cm_commissioner/user_profile.rb +2 -0
  32. data/app/overrides/spree/admin/products/_form/enable_confirmation_email.html.erb.deface +13 -0
  33. data/app/overrides/spree/admin/products/_form/sellable_on_platform.html.erb.deface +12 -0
  34. data/app/serializers/spree/v2/storefront/taxon_serializer_decorator.rb +4 -1
  35. data/app/services/spree_cm_commissioner/api_caches/invalidate.rb +28 -17
  36. data/app/services/spree_cm_commissioner/api_caches/invalidate_all.rb +5 -7
  37. data/app/services/spree_cm_commissioner/draft_orders/create.rb +6 -3
  38. data/app/services/spree_cm_commissioner/events/publish_stock_statuses_to_firestore.rb +30 -4
  39. data/config/initializers/spree_permitted_attributes.rb +4 -0
  40. data/config/locales/en.yml +2 -0
  41. data/config/locales/km.yml +1 -0
  42. data/config/routes.rb +14 -6
  43. data/db/migrate/20261103000001_add_sales_channel_to_spree_products.rb +14 -0
  44. data/lib/spree_cm_commissioner/s3_url_generator.rb +13 -1
  45. data/lib/spree_cm_commissioner/tenant_slug_constraint.rb +23 -0
  46. data/lib/spree_cm_commissioner/version.rb +1 -1
  47. data/lib/spree_cm_commissioner.rb +1 -0
  48. metadata +8 -8
  49. data/app/controllers/spree/api/v2/organizer/images_controller.rb +0 -56
  50. data/app/controllers/spree/api/v2/organizer/s3_signed_urls_controller.rb +0 -29
  51. data/app/controllers/spree/api/v2/organizer/ticket_images_controller.rb +0 -27
  52. data/app/controllers/spree/api/v2/organizer/tickets_controller.rb +0 -97
  53. data/app/serializers/spree/v2/organizer/image_serializer.rb +0 -11
  54. data/app/serializers/spree/v2/organizer/s3_signed_url_serializer.rb +0 -10
  55. data/app/serializers/spree/v2/organizer/ticket_serializer.rb +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2828d409efc450a1771a72ddee9866379996fbe09b29ee4eec5b446ed4d6acfc
4
- data.tar.gz: 0cf499cd5d3bb7947385761cfff6c569a9180f96fe60426fa3ba802dece704a9
3
+ metadata.gz: ceb90915f56d7b2621b44c00abe7b65adbdf5093f566d3b801333496c3333402
4
+ data.tar.gz: 418a9332f9ddf34711f4c251c5c6040388e191efa8f2f8eefc11d12777a6f79a
5
5
  SHA512:
6
- metadata.gz: c2531492454f38d71fbcb6115b97d442b29631b49b92cc5c8961cc395b761014bf24c53a99301150a5480ac42484838319dbac8c7d8f3d924708a6585bc7f9b1
7
- data.tar.gz: 7e4bf0769cfdc8364a492fdb6fabbce2d72f1da7df9289dbbf68562312d3604880918f5eab80aa4b3e5f4098366fa8bc7c2ae38907bd8fc2a9b5fae60e92bcc0
6
+ metadata.gz: 6ef4fcfc3e0feb5af8d54f06a7321e1369a35b87f625d8465a70edb5c2ac2502e180812ba60fb3d5d081bc746367bcb756e9124416719f73af0143cea86242b6
7
+ data.tar.gz: 46af2a2e96874f557e5921b671838148b148791a408c0c55b26d7647fb4fc1fc11ea775d25ee5998da402d0f7cac4b00374bb269ee21eaef5edc088bb1053bdd
data/Gemfile.lock CHANGED
@@ -34,7 +34,7 @@ GIT
34
34
  PATH
35
35
  remote: .
36
36
  specs:
37
- spree_cm_commissioner (2.14.7)
37
+ spree_cm_commissioner (2.14.8)
38
38
  activerecord-multi-tenant
39
39
  activerecord_json_validator (~> 2.1, >= 2.1.3)
40
40
  aws-sdk-cloudfront
@@ -88,8 +88,16 @@ module SpreeCmCommissioner
88
88
  ContentCachable.cdn_max_age(cdn_cache_config&.dig(:max_age))
89
89
  end
90
90
 
91
+ # A tenant API is cacheable only on its /api/v2/:tenant_slug/ path. The legacy /api/v2/tenant/
92
+ # path takes the tenant from the OAuth token, which is not in the cache key, so caching it would
93
+ # serve one tenant's response to every other tenant.
91
94
  def cdn_cacheable?
92
- request.get? && cdn_cache_config.present? && cdn_cache_config[:actions].include?(action_name)
95
+ request.get? && cdn_cache_config.present? && cdn_cache_config[:actions].include?(action_name) &&
96
+ (!tenant_api? || params[:tenant_slug].present?)
97
+ end
98
+
99
+ def tenant_api?
100
+ self.class.name.to_s.start_with?('Spree::Api::V2::Tenant::')
93
101
  end
94
102
 
95
103
  def set_cache_control_for_cdn
@@ -0,0 +1,30 @@
1
+ module SpreeCmCommissioner
2
+ # For per-guest endpoints that touch the guest's ID card photos. The app calls these during
3
+ # checkout (order token, or the logged-in owner) and after it from pending guests (logged-in
4
+ # owner of a complete order). That's why ownership is checked here and not with the
5
+ # `:update, Order` ability, which refuses completed orders.
6
+ module GuestOwnerAuthorization
7
+ extend ActiveSupport::Concern
8
+
9
+ private
10
+
11
+ # 404 rather than 403 for someone else's guest, so sequential guest ids can't be probed.
12
+ def authorized_guest
13
+ @authorized_guest ||= begin
14
+ guest = SpreeCmCommissioner::Guest.find(params[:guest_id])
15
+ raise ActiveRecord::RecordNotFound unless guest_owned_by_requester?(guest)
16
+
17
+ guest
18
+ end
19
+ end
20
+
21
+ def guest_owned_by_requester?(guest)
22
+ order = guest.line_item&.order
23
+ user = spree_current_user
24
+ return true if user.present? && (guest.user_id == user.id || order&.user_id == user.id)
25
+
26
+ token = request.headers['X-Spree-Order-Token'].presence || params[:order_token].presence
27
+ token.present? && order&.token.present? && ActiveSupport::SecurityUtils.secure_compare(order.token, token)
28
+ end
29
+ end
30
+ end
@@ -1,14 +1,25 @@
1
1
  module Spree
2
2
  module Admin
3
3
  class StockManagementsController < Spree::Admin::ResourceController
4
+ include Spree::Admin::ProductConcern
5
+
4
6
  skip_before_action :load_resource
5
7
 
6
8
  before_action :load_parent
7
9
 
8
10
  helper SpreeCmCommissioner::Admin::InventoryItemsHelper
9
11
 
12
+ # Same lookup as Spree's ProductsController: friendly.find accepts both slug and id links
13
+ # (e.g. /admin/products/938/stock_managements) and raises on a miss instead of leaving @product nil.
10
14
  def load_parent
11
- @product = Spree::Product.find_by(slug: params[:product_id])
15
+ @product = product_scope.friendly.find(params[:product_id])
16
+ end
17
+
18
+ # ResourceController's handler names model_class (StockItem) in the flash and redirects to
19
+ # collection_url (/admin/stock_items), which has no GET route — so point both at products instead.
20
+ def resource_not_found
21
+ flash[:error] = flash_message_for(Spree::Product.new, :not_found)
22
+ redirect_to spree.admin_products_path
12
23
  end
13
24
 
14
25
  def index
@@ -3,6 +3,10 @@ module Spree
3
3
  module V2
4
4
  module Organizer
5
5
  class InvitesController < ::Spree::Api::V2::Organizer::BaseController
6
+ # Accepting grants the operator role, so it must be the invitee's own decision: the
7
+ # token alone only proves someone has the link.
8
+ before_action :require_spree_current_user, only: :update
9
+
6
10
  def show
7
11
  if invite&.url_valid?
8
12
  render_serialized_payload { serialize_resource(invite) }
@@ -12,7 +16,7 @@ module Spree
12
16
  end
13
17
 
14
18
  def update
15
- context = invite.confirm(params[:user_id])
19
+ context = invite.confirm(spree_current_user.id)
16
20
 
17
21
  if context.success?
18
22
  render_serialized_payload { serialize_resource(context.invite) }
@@ -4,6 +4,7 @@ module Spree
4
4
  module Storefront
5
5
  class GuestsController < ::Spree::Api::V2::ResourceController
6
6
  include OrderConcern
7
+ include SpreeCmCommissioner::GuestOwnerAuthorization
7
8
 
8
9
  # raise 404 when no order, except for show
9
10
  before_action :ensure_order, except: [:show]
@@ -23,9 +24,9 @@ module Spree
23
24
  SpreeCmCommissioner::V2::Storefront::GuestSerializer
24
25
  end
25
26
 
27
+ # The serializer includes the guest's ID card, so show must be as strict as id_cards.
26
28
  def show
27
- @guest = SpreeCmCommissioner::Guest.find(params[:id])
28
- render_serialized_payload { serialize_resource(@guest) }
29
+ render_serialized_payload { serialize_resource(owned_guest) }
29
30
  end
30
31
 
31
32
  def create
@@ -44,9 +45,7 @@ module Spree
44
45
  end
45
46
 
46
47
  def update
47
- spree_authorize! :update, spree_current_order, order_token unless guest_for_update.pre_registration?
48
-
49
- result = SpreeCmCommissioner::Guests::Update.call(guest: guest_for_update, guest_params: guest_params)
48
+ result = SpreeCmCommissioner::Guests::Update.call(guest: owned_guest, guest_params: guest_params)
50
49
 
51
50
  if result.success?
52
51
  render_serialized_payload { serialize_resource(result.value[:guest]) }
@@ -66,8 +65,15 @@ module Spree
66
65
  @parent ||= spree_current_order.line_items.find(params[:line_item_id])
67
66
  end
68
67
 
69
- def guest_for_update
70
- @guest_for_update ||= SpreeCmCommissioner::Guest.find(params[:id])
68
+ # Checked against the guest's own order: spree_current_order is just the caller's cart,
69
+ # which says nothing about whether they own this guest.
70
+ def owned_guest
71
+ @owned_guest ||= begin
72
+ guest = SpreeCmCommissioner::Guest.find(params[:id])
73
+ raise ActiveRecord::RecordNotFound unless guest_owned_by_requester?(guest)
74
+
75
+ guest
76
+ end
71
77
  end
72
78
 
73
79
  def guest_params
@@ -3,12 +3,14 @@ module Spree
3
3
  module V2
4
4
  module Storefront
5
5
  class IdCardsController < ::Spree::Api::V2::ResourceController
6
+ include SpreeCmCommissioner::GuestOwnerAuthorization
7
+
6
8
  def create
7
9
  context = SpreeCmCommissioner::GuestIdCardManager.call(
8
10
  card_type: id_card_params[:card_type],
9
11
  front_image_url: id_card_params[:front_image_url],
10
12
  back_image_url: id_card_params[:back_image_url],
11
- guest_id: id_card_params[:guest_id]
13
+ guest: authorized_guest
12
14
  )
13
15
 
14
16
  if context.success?
@@ -23,7 +25,7 @@ module Spree
23
25
  card_type: id_card_params[:card_type],
24
26
  front_image_url: id_card_params[:front_image_url],
25
27
  back_image_url: id_card_params[:back_image_url],
26
- guest_id: id_card_params[:guest_id]
28
+ guest: authorized_guest
27
29
  )
28
30
 
29
31
  if context.success?
@@ -34,7 +36,7 @@ module Spree
34
36
  end
35
37
 
36
38
  def destroy
37
- id_card = SpreeCmCommissioner::IdCard.find(params[:id])
39
+ id_card = SpreeCmCommissioner::IdCard.find_by!(id: params[:id], guest_id: authorized_guest.id)
38
40
 
39
41
  if id_card.destroy
40
42
  render_serialized_payload(204) { serialize_resource(id_card) }
@@ -46,7 +48,7 @@ module Spree
46
48
  private
47
49
 
48
50
  def id_card_params
49
- params.permit(:card_type, :front_image_url, :back_image_url, :guest_id)
51
+ params.permit(:card_type, :front_image_url, :back_image_url)
50
52
  end
51
53
 
52
54
  def model_class
@@ -4,7 +4,7 @@ module Spree
4
4
  module Storefront
5
5
  module ProductsControllerDecorator
6
6
  def scope
7
- super.where(preview: false)
7
+ super.where(preview: false).sellable_on(:platform)
8
8
  end
9
9
  end
10
10
  end
@@ -12,10 +12,11 @@ module Spree
12
12
  end
13
13
 
14
14
  def resource
15
+ # Never take bucket_name/object_key from params: the key is always generated server-side.
16
+ # Reachable without login (app token only), so S3 also enforces the upload size.
15
17
  SpreeCmCommissioner::S3PresignedUrlBuilder.call(
16
- bucket_name: params[:bucket_name],
17
- object_key: params[:object_key],
18
- file_name: params[:file_name]
18
+ file_name: params[:file_name],
19
+ content_length_range: 1..SpreeCmCommissioner::UserUploadedImage::MAX_SIZE
19
20
  )
20
21
  end
21
22
 
@@ -8,14 +8,34 @@ module Spree
8
8
  set_current_tenant_through_filter
9
9
  before_action :require_tenant
10
10
 
11
+ # /api/v2/:tenant_slug/... takes the tenant from the URL so the CDN, which caches by URL,
12
+ # never serves one tenant's response to another. Legacy /api/v2/tenant/... still takes it
13
+ # from the OAuth token and is not CDN-cached (see SpreeCmCommissioner::ContentCachable).
11
14
  def require_tenant
12
- raise Doorkeeper::Errors::DoorkeeperError if doorkeeper_token&.application.nil?
13
- raise Doorkeeper::Errors::DoorkeeperError if doorkeeper_token.application.tenant.nil?
15
+ @tenant = tenant_from_path || tenant_from_token
16
+ raise Doorkeeper::Errors::DoorkeeperError if @tenant.nil?
14
17
 
15
- @tenant = doorkeeper_token.application.tenant
16
18
  set_current_tenant(@tenant)
17
19
  end
18
20
 
21
+ # Slugs only (current or FriendlyId history, so a renamed tenant keeps working in shipped
22
+ # apps), never numeric ids, so each tenant has one cacheable URL. A token minted for another
23
+ # tenant's app, or for the BookMe+ storefront app, is refused rather than letting its user
24
+ # act inside this tenant.
25
+ def tenant_from_path
26
+ return if params[:tenant_slug].blank?
27
+
28
+ # FriendlyId's own finder (slug + history, never the numeric id), not a dynamic finder.
29
+ tenant = SpreeCmCommissioner::Tenant.friendly.find_by_friendly_id(params[:tenant_slug]) # rubocop:disable Rails/DynamicFindBy
30
+ raise Doorkeeper::Errors::DoorkeeperError if doorkeeper_token && doorkeeper_token.application&.tenant_id != tenant.id
31
+
32
+ tenant
33
+ end
34
+
35
+ def tenant_from_token
36
+ doorkeeper_token&.application&.tenant
37
+ end
38
+
19
39
  def current_vendor
20
40
  @current_vendor ||= @tenant.active_vendor
21
41
  end
@@ -192,7 +192,7 @@ module Spree
192
192
  end
193
193
 
194
194
  def load_variant
195
- @variant = current_store.variants.find(add_item_params[:variant_id])
195
+ @variant = current_store.variants.joins(:product).merge(Spree::Product.sellable_on(:direct)).find(add_item_params[:variant_id])
196
196
  end
197
197
 
198
198
  def render_error_item_quantity
@@ -3,12 +3,14 @@ module Spree
3
3
  module V2
4
4
  module Tenant
5
5
  class IdCardsController < BaseController
6
+ include SpreeCmCommissioner::GuestOwnerAuthorization
7
+
6
8
  def create
7
9
  context = SpreeCmCommissioner::GuestIdCardManager.call(
8
10
  card_type: id_card_params[:card_type],
9
11
  front_image_url: id_card_params[:front_image_url],
10
12
  back_image_url: id_card_params[:back_image_url],
11
- guest_id: id_card_params[:guest_id]
13
+ guest: authorized_guest
12
14
  )
13
15
 
14
16
  if context.success?
@@ -23,7 +25,7 @@ module Spree
23
25
  card_type: id_card_params[:card_type],
24
26
  front_image_url: id_card_params[:front_image_url],
25
27
  back_image_url: id_card_params[:back_image_url],
26
- guest_id: id_card_params[:guest_id]
28
+ guest: authorized_guest
27
29
  )
28
30
 
29
31
  if context.success?
@@ -34,7 +36,7 @@ module Spree
34
36
  end
35
37
 
36
38
  def destroy
37
- id_card = SpreeCmCommissioner::IdCard.find(params[:id])
39
+ id_card = SpreeCmCommissioner::IdCard.find_by!(id: params[:id], guest_id: authorized_guest.id)
38
40
 
39
41
  if id_card.destroy
40
42
  head :no_content
@@ -46,7 +48,7 @@ module Spree
46
48
  private
47
49
 
48
50
  def id_card_params
49
- params.permit(:card_type, :front_image_url, :back_image_url, :guest_id)
51
+ params.permit(:card_type, :front_image_url, :back_image_url)
50
52
  end
51
53
 
52
54
  def model_class
@@ -7,8 +7,7 @@ module Spree
7
7
  # vendor_id is a required query param, and payout is an optional one (caller
8
8
  # passes Order#required_payway_payout? — see PaymentMethods::Find). A vendor id
9
9
  # is globally unique and belongs to exactly one tenant, so it is what identifies
10
- # tenant, vendor and store here — the tenant itself comes from the Doorkeeper
11
- # token, not the URL.
10
+ # vendor and store here; the tenant comes from BaseController#require_tenant.
12
11
  #
13
12
  # IMPORTANT for caching (see SpreeCmCommissioner::ContentCachable): the CDN
14
13
  # must include vendor_id in its cache key. If query strings are stripped,
@@ -21,7 +21,7 @@ module Spree
21
21
 
22
22
  def scope
23
23
  MultiTenant.with(@tenant) do
24
- model_class.where(tenant_id: MultiTenant.current_tenant_id, preview: false)
24
+ model_class.where(tenant_id: MultiTenant.current_tenant_id, preview: false).sellable_on(:direct)
25
25
  end
26
26
  end
27
27
 
@@ -12,10 +12,11 @@ module Spree
12
12
  end
13
13
 
14
14
  def resource
15
+ # Never take bucket_name/object_key from params: the key is always generated server-side.
16
+ # Reachable without login (app token only), so S3 also enforces the upload size.
15
17
  SpreeCmCommissioner::S3PresignedUrlBuilder.call(
16
- bucket_name: params[:bucket_name],
17
- object_key: params[:object_key],
18
- file_name: params[:file_name]
18
+ file_name: params[:file_name],
19
+ content_length_range: 1..SpreeCmCommissioner::UserUploadedImage::MAX_SIZE
19
20
  )
20
21
  end
21
22
 
@@ -1,6 +1,7 @@
1
1
  module SpreeCmCommissioner
2
2
  class GuestIdCardManager < BaseInteractor
3
- delegate :card_type, :front_image_url, :back_image_url, :guest_id, to: :context
3
+ # guest: the caller must already have checked the requester owns this guest.
4
+ delegate :card_type, :front_image_url, :back_image_url, :guest, to: :context
4
5
 
5
6
  def call
6
7
  if guest.id_card.blank? && front_image_url.present?
@@ -31,31 +32,39 @@ module SpreeCmCommissioner
31
32
  context.result = guest.id_card
32
33
  end
33
34
 
34
- def guest
35
- SpreeCmCommissioner::Guest.find(guest_id)
36
- end
37
-
38
35
  def manage_card_type(id_card)
39
36
  id_card.update(card_type: card_type)
40
37
  end
41
38
 
39
+ # Fails this interactor's own context: failing the updater's context instead raises a Failure
40
+ # that Interactor#run swallows, so the request would report success with nothing attached.
42
41
  def manage_front_image(id_card)
43
- context = SpreeCmCommissioner::IdCardImageUpdater.call(
42
+ return if unchanged_image?(id_card.front_image, front_image_url)
43
+
44
+ result = SpreeCmCommissioner::IdCardImageUpdater.call(
44
45
  id_card: id_card,
45
46
  image_url: front_image_url,
46
47
  type: 'front_image'
47
48
  )
48
49
 
49
- context.fail!(message: 'Failed to update front image') unless context.success?
50
+ context.fail!(message: 'Failed to update front image') unless result.success?
50
51
  end
51
52
 
52
53
  def manage_back_image(id_card)
53
- context = SpreeCmCommissioner::IdCardImageUpdater.call(
54
+ return if unchanged_image?(id_card.back_image, back_image_url)
55
+
56
+ result = SpreeCmCommissioner::IdCardImageUpdater.call(
54
57
  id_card: id_card,
55
58
  image_url: back_image_url,
56
59
  type: 'back_image'
57
60
  )
58
- context.fail!(message: 'Failed to update back image') unless context.success?
61
+ context.fail!(message: 'Failed to update back image') unless result.success?
62
+ end
63
+
64
+ # The app sends the whole card back on every edit, so a side the user didn't replace arrives as
65
+ # that image's existing URL rather than a new upload. Keep it instead of failing the fetch.
66
+ def unchanged_image?(image, url)
67
+ image.present? && SpreeCmCommissioner::S3UrlGenerator.upload_key(url).nil?
59
68
  end
60
69
  end
61
70
  end
@@ -4,10 +4,9 @@ module SpreeCmCommissioner
4
4
 
5
5
  def call
6
6
  presigned_url = SpreeCmCommissioner::S3UrlGenerator.s3_presigned_url(context.image_url)
7
+ response = Faraday.get(presigned_url) if presigned_url
7
8
 
8
- response = Faraday.get(presigned_url)
9
-
10
- if response.success?
9
+ if response&.success?
11
10
  id_card = context.id_card
12
11
  io = StringIO.new(response.body)
13
12
  filename = File.basename(context.image_url)
@@ -20,7 +19,11 @@ module SpreeCmCommissioner
20
19
  else
21
20
  context.fail!(message: id_card_image.errors.full_messages.join(','))
22
21
  end
22
+ else
23
+ context.fail!(message: I18n.t('s3.image.upload.fail'))
23
24
  end
25
+ rescue Interactor::Failure
26
+ raise
24
27
  rescue StandardError => e
25
28
  context.fail!(message: "Error fetching the remote image: #{e.message}")
26
29
  end
@@ -2,10 +2,9 @@ module SpreeCmCommissioner
2
2
  class ImageSaver < BaseInteractor
3
3
  def call
4
4
  presigned_url = SpreeCmCommissioner::S3UrlGenerator.s3_presigned_url(context.url)
5
+ response = Faraday.get(presigned_url) if presigned_url
5
6
 
6
- response = Faraday.get(presigned_url)
7
-
8
- if response.success?
7
+ if response&.success?
9
8
  io = StringIO.new(response.body)
10
9
  filename = File.basename(context.url)
11
10
 
@@ -20,6 +19,8 @@ module SpreeCmCommissioner
20
19
  else
21
20
  context.fail!(message: I18n.t('s3.image.upload.fail'))
22
21
  end
22
+ rescue Interactor::Failure
23
+ raise
23
24
  rescue StandardError => e
24
25
  context.fail!(message: e.message)
25
26
  end
@@ -3,10 +3,9 @@ module SpreeCmCommissioner
3
3
  # url: 'https://mybucket.s3.ap-southeast-1.amazonaws.com/uploads/5f31/1697038252270.jpg'
4
4
  def call
5
5
  presigned_url = SpreeCmCommissioner::S3UrlGenerator.s3_presigned_url(context.url)
6
+ response = Faraday.get(presigned_url) if presigned_url
6
7
 
7
- response = Faraday.get(presigned_url)
8
-
9
- if response.success?
8
+ if response&.success?
10
9
  user = context.user
11
10
  io = StringIO.new(response.body)
12
11
  filename = File.basename(context.url)
@@ -20,7 +19,11 @@ module SpreeCmCommissioner
20
19
  else
21
20
  context.fail!(message: profile.errors.full_messages.join(','))
22
21
  end
22
+ else
23
+ context.fail!(message: I18n.t('s3.image.upload.fail'))
23
24
  end
25
+ rescue Interactor::Failure
26
+ raise
24
27
  rescue StandardError => e
25
28
  context.fail!(message: "Error fetching the remote image: #{e.message}")
26
29
  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, if: :confirmation_delivered?, do: :deliver_order_confirmation_email
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
@@ -1,5 +1,7 @@
1
1
  module SpreeCmCommissioner
2
2
  class BackImage < SpreeCmCommissioner::Asset
3
+ include SpreeCmCommissioner::UserUploadedImage
4
+
3
5
  def asset_styles
4
6
  {
5
7
  mini: '187x67>',
@@ -1,5 +1,7 @@
1
1
  module SpreeCmCommissioner
2
2
  class FrontImage < SpreeCmCommissioner::Asset
3
+ include SpreeCmCommissioner::UserUploadedImage
4
+
3
5
  def asset_styles
4
6
  {
5
7
  mini: '187x67>',
@@ -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