From 1564d01188d08d37ab3d162f9dde293876c36a27 Mon Sep 17 00:00:00 2001 From: kipsang Date: Sat, 2 May 2026 08:48:14 +0300 Subject: [PATCH 01/10] Refactor clone store functionality and enhance vendor autologin - Updated CloneRequestCreator to resolve source store more efficiently. - Enhanced CloneRequestPresenter to include vendor metadata and auto-login details. - Modified CloneRequestProvisioner to provision admin users and link them to vendors for autologin. - Improved ShippingCategories and StockLocations duplicators to use global queries instead of vendor-specific ones. - Added new routes for AI theme management including theme creation, preview, and publishing. - Introduced tests for CloneStoresController, CloneRequestProvisioner, and AI theme synchronization. - Updated existing tests to cover new functionality and ensure proper vendor and admin user handling. --- .../api/v2/platform/ai/base_controller.rb | 117 ++++ .../platform/ai/page_sections_controller.rb | 57 ++ .../v2/platform/ai/theme_pages_controller.rb | 64 +++ .../platform/ai/theme_versions_controller.rb | 43 ++ .../api/v2/platform/ai/themes_controller.rb | 100 ++++ .../clone_store/clone_store_controller.rb | 58 +- .../olitt/clone_store/clone_store_helpers.rb | 2 +- .../olitt/clone_store/product_helpers.rb | 2 +- app/models/spree/ai_theme_store_decorator.rb | 14 + .../spree/olitt/clone_store/clone_request.rb | 18 +- .../spree/olitt/clone_store/ai_theme/sync.rb | 535 ++++++++++++++++++ .../clone_store/clone_request_creator.rb | 9 +- .../clone_store/clone_request_presenter.rb | 34 +- .../clone_store/clone_request_provisioner.rb | 84 ++- .../shipping_categories_duplicator.rb | 2 +- .../duplicators/stock_locations_duplicator.rb | 4 +- config/routes.rb | 13 + .../platform/clone_stores_controller_spec.rb | 72 +++ .../clone_store_controller_spec.rb | 7 + .../olitt/clone_store/product_helpers_spec.rb | 21 + .../olitt/clone_store/ai_theme/sync_spec.rb | 253 +++++++++ .../clone_request_presenter_spec.rb | 17 +- .../clone_request_provisioner_spec.rb | 32 ++ .../shipping_categories_duplicator_spec.rb | 8 +- .../stock_locations_duplicator_spec.rb | 20 +- 25 files changed, 1530 insertions(+), 56 deletions(-) create mode 100644 app/controllers/spree/api/v2/platform/ai/base_controller.rb create mode 100644 app/controllers/spree/api/v2/platform/ai/page_sections_controller.rb create mode 100644 app/controllers/spree/api/v2/platform/ai/theme_pages_controller.rb create mode 100644 app/controllers/spree/api/v2/platform/ai/theme_versions_controller.rb create mode 100644 app/controllers/spree/api/v2/platform/ai/themes_controller.rb create mode 100644 app/models/spree/ai_theme_store_decorator.rb create mode 100644 app/services/spree/olitt/clone_store/ai_theme/sync.rb create mode 100644 spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb create mode 100644 spec/services/spree/olitt/clone_store/ai_theme/sync_spec.rb create mode 100644 spec/services/spree/olitt/clone_store/clone_request_provisioner_spec.rb diff --git a/app/controllers/spree/api/v2/platform/ai/base_controller.rb b/app/controllers/spree/api/v2/platform/ai/base_controller.rb new file mode 100644 index 0000000..8903bce --- /dev/null +++ b/app/controllers/spree/api/v2/platform/ai/base_controller.rb @@ -0,0 +1,117 @@ +module Spree + module Api + module V2 + module Platform + module Ai + class BaseController < Spree::BaseController + skip_forgery_protection + + rescue_from Doorkeeper::Errors::DoorkeeperError, with: :render_unauthorized + rescue_from ActiveRecord::RecordNotUnique, with: :render_bad_request_exception + rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid + + before_action :force_json_request_format + before_action :validate_token_client + before_action :authorize_ai_theme_request! + + private + + def force_json_request_format + request.format = :json + end + + def validate_token_client + return if doorkeeper_token.nil? + + raise Doorkeeper::Errors::DoorkeeperError if doorkeeper_token.application.nil? + end + + def render_unauthorized(_exception) + render_api_error('Unauthorized', :unauthorized) + end + + def render_api_error(message, status) + render json: { error: message }, status: status + end + + def render_bad_request_exception(exception) + render json: { errors: [exception.cause&.message || exception.message] }, status: :bad_request + end + + def render_record_invalid(exception) + render json: { errors: exception.record.errors.full_messages.presence || [exception.message] }, status: :bad_request + end + + def spree_current_user + return nil unless doorkeeper_token + return nil if doorkeeper_token.resource_owner_id.nil? + return @spree_current_user if defined?(@spree_current_user) + + @spree_current_user ||= doorkeeper_token.resource_owner + end + + def superuser_with_global_admin_role?(user) + return false unless user.present? + + user.role_users.joins(:role).exists?( + spree_roles: { name: Spree::Role::ADMIN_ROLE }, + resource_type: nil, + resource_id: nil + ) + end + + def render_forbidden + render_api_error(I18n.t('spree.forbidden'), :forbidden) + end + + def normalize_errors(errors) + Array(errors).flatten.compact.flat_map do |error| + next error.full_messages if error.respond_to?(:full_messages) + + error.to_s + end + end + + def authorize_ai_theme_request! + scopes = %i[read admin] + scopes = %i[write admin] unless %w[show].include?(action_name) + doorkeeper_authorize!(*scopes) + + return if spree_current_user.nil? + return if superuser_with_global_admin_role?(spree_current_user) + + store = store_for_authorization + return if store.blank? + + ability = Spree::VendorAbility.new(spree_current_user) rescue nil + return if ability && (ability.can?(:manage, store) || ability.can?(:admin, store)) + + render_forbidden + end + + def store_for_authorization + store_id = params[:store_id] || params.dig(:theme, :store_id) + return Spree::Store.find_by(id: store_id) if store_id.present? + + theme_id = params[:theme_theme_id] || params[:theme_id] + return theme_store(theme_id) if theme_id.present? + + page_id = params[:page_page_id] || params[:page_id] + return if page_id.blank? + + page = Spree::Page.find_by(id: page_id) + return if page.blank? + + theme_store(page.try(:pageable_id)) || page.try(:pageable) + end + + def theme_store(theme_or_theme_id) + theme = theme_or_theme_id.is_a?(Spree::Theme) ? theme_or_theme_id : Spree::Theme.find_by(id: theme_or_theme_id) + theme&.store + end + end + end + end + end + end +end \ No newline at end of file diff --git a/app/controllers/spree/api/v2/platform/ai/page_sections_controller.rb b/app/controllers/spree/api/v2/platform/ai/page_sections_controller.rb new file mode 100644 index 0000000..feecba5 --- /dev/null +++ b/app/controllers/spree/api/v2/platform/ai/page_sections_controller.rb @@ -0,0 +1,57 @@ +module Spree + module Api + module V2 + module Platform + module Ai + class PageSectionsController < BaseController + def create + page_id = params[:page_page_id] || params[:page_id] + page = Spree::Page.find_by(id: page_id) + return render_not_found('Page not found') unless page + + section = sync_service.upsert_section(page, section_params) + return render_errors(sync_service.errors) if section.nil? + + render json: sync_service.section_payload(section), status: :created + end + + private + + def sync_service + @sync_service ||= Spree::Olitt::CloneStore::AiTheme::Sync.new + end + + def section_params + params.fetch(:section, params).permit( + :id, + :type, + :kind, + :class_name, + :name, + :position, + :prompt, + :status, + :version, + content: {}, + settings: {}, + preferences: {}, + blocks: [ + :id, :type, :kind, :class_name, :name, :position, + { content: {}, settings: {}, preferences: {} } + ] + ).to_h.symbolize_keys + end + + def render_errors(errors, status: :unprocessable_entity) + render json: { errors: normalize_errors(errors) }, status: status + end + + def render_not_found(message = 'Page not found') + render json: { errors: [message] }, status: :not_found + end + end + end + end + end + end +end \ No newline at end of file diff --git a/app/controllers/spree/api/v2/platform/ai/theme_pages_controller.rb b/app/controllers/spree/api/v2/platform/ai/theme_pages_controller.rb new file mode 100644 index 0000000..dbba47c --- /dev/null +++ b/app/controllers/spree/api/v2/platform/ai/theme_pages_controller.rb @@ -0,0 +1,64 @@ +module Spree + module Api + module V2 + module Platform + module Ai + class ThemePagesController < BaseController + def create + theme_id = params[:theme_theme_id] || params[:theme_id] + theme = Spree::Theme.find_by(id: theme_id) + return render_not_found('Theme not found') unless theme + + page = sync_service.upsert_page(theme, page_params) + return render_errors(sync_service.errors) if page.nil? + + render json: sync_service.page_payload(page), status: :created + end + + private + + def sync_service + @sync_service ||= Spree::Olitt::CloneStore::AiTheme::Sync.new + end + + def page_params + params.fetch(:page, params).permit( + :id, + :theme_id, + :type, + :kind, + :class_name, + :name, + :title, + :slug, + :meta_title, + :meta_description, + :meta_keywords, + :visible, + :prompt, + :status, + :version, + spec: {}, + sections: [ + :id, :type, :kind, :class_name, :name, :position, + { content: {}, settings: {}, preferences: {}, blocks: [ + :id, :type, :kind, :class_name, :name, :position, + { content: {}, settings: {}, preferences: {} } + ] } + ] + ).to_h.symbolize_keys + end + + def render_errors(errors, status: :unprocessable_entity) + render json: { errors: normalize_errors(errors) }, status: status + end + + def render_not_found(message = 'Theme not found') + render json: { errors: [message] }, status: :not_found + end + end + end + end + end + end +end \ No newline at end of file diff --git a/app/controllers/spree/api/v2/platform/ai/theme_versions_controller.rb b/app/controllers/spree/api/v2/platform/ai/theme_versions_controller.rb new file mode 100644 index 0000000..0b625a8 --- /dev/null +++ b/app/controllers/spree/api/v2/platform/ai/theme_versions_controller.rb @@ -0,0 +1,43 @@ +module Spree + module Api + module V2 + module Platform + module Ai + class ThemeVersionsController < BaseController + def create + theme_id = params[:theme_theme_id] || params[:theme_id] + theme = Spree::Theme.find_by(id: theme_id) + return render_not_found('Theme not found') unless theme + + version = sync_service.snapshot_version(theme, version_params) + return render_errors(sync_service.errors) if version.nil? + + render json: { data: { id: version['revision'].to_s, type: 'ai_theme_version', attributes: version } }, status: :created + end + + private + + def sync_service + @sync_service ||= Spree::Olitt::CloneStore::AiTheme::Sync.new + end + + def version_params + params.fetch(:version, params).permit( + :checksum, + spec: {} + ).to_h.symbolize_keys + end + + def render_errors(errors, status: :unprocessable_entity) + render json: { errors: normalize_errors(errors) }, status: status + end + + def render_not_found(message = 'Theme not found') + render json: { errors: [message] }, status: :not_found + end + end + end + end + end + end +end \ No newline at end of file diff --git a/app/controllers/spree/api/v2/platform/ai/themes_controller.rb b/app/controllers/spree/api/v2/platform/ai/themes_controller.rb new file mode 100644 index 0000000..c19f484 --- /dev/null +++ b/app/controllers/spree/api/v2/platform/ai/themes_controller.rb @@ -0,0 +1,100 @@ +module Spree + module Api + module V2 + module Platform + module Ai + class ThemesController < BaseController + def create + store = store_from_params + return render_not_found('Store not found') if store.blank? + + service = Spree::Olitt::CloneStore::AiTheme::Sync.new(store: store) + theme = service.upsert_theme(theme_params) + return render_errors(service.errors) if theme.nil? + + render json: service.theme_payload(theme), status: :created + end + + def show + theme = find_theme + return render_not_found unless theme + + render json: sync_service.theme_payload(theme), status: :ok + end + + def preview + theme = find_theme + return render_not_found unless theme + + token = sync_service.preview_theme(theme) + return render_errors(sync_service.errors) if token.nil? + + render json: sync_service.theme_payload(theme), status: :accepted + end + + def publish + theme = find_theme + return render_not_found unless theme + + published = sync_service.publish_theme(theme) + return render_errors(sync_service.errors) if published.nil? + + render json: sync_service.theme_payload(published), status: :ok + end + + private + + def sync_service + @sync_service ||= Spree::Olitt::CloneStore::AiTheme::Sync.new(store: store_from_params) + end + + def store_from_params + @store_from_params ||= Spree::Store.find_by(id: theme_params[:store_id] || params[:store_id]) + end + + def find_theme + theme_id = params[:theme_theme_id] || params[:theme_id] || theme_params[:theme_theme_id] || theme_params[:theme_id] || theme_params[:id] + return nil if theme_id.blank? + + Spree::Theme.find_by(id: theme_id) + end + + def theme_params + params.fetch(:theme, params).permit( + :id, + :theme_id, + :store_id, + :name, + :default, + :prompt, + :status, + :version, + :checksum, + spec: {}, + pages: [ + :id, :type, :kind, :class_name, :name, :title, :slug, :meta_title, :meta_description, + :meta_keywords, :visible, + { sections: [ + :id, :type, :kind, :class_name, :name, :position, + { content: {}, settings: {}, preferences: {}, blocks: [ + :id, :type, :kind, :class_name, :name, :position, + { content: {}, settings: {}, preferences: {} } + ] } + ] } + ] + ).to_h.symbolize_keys + end + + def render_errors(errors, status: :unprocessable_entity) + render json: { errors: normalize_errors(errors) }, status: status + end + + def render_not_found(message = 'Theme not found') + render json: { errors: [message] }, status: :not_found + end + end + end + end + end + end +end \ No newline at end of file diff --git a/app/controllers/spree/olitt/clone_store/clone_store_controller.rb b/app/controllers/spree/olitt/clone_store/clone_store_controller.rb index 9b7743b..ce3420b 100644 --- a/app/controllers/spree/olitt/clone_store/clone_store_controller.rb +++ b/app/controllers/spree/olitt/clone_store/clone_store_controller.rb @@ -26,8 +26,10 @@ def render_error(duplicator:) def handle_create_vendor(email, password, password_confirmation) user_email = email.to_s.strip.downcase @vendor = find_or_create_vendor(user_email) - user = find_or_create_user(user_email, password, password_confirmation) - assign_vendor_role(user, @vendor) + legacy_user = existing_legacy_user(user_email) + admin_user = find_or_create_admin_user(user_email, password, password_confirmation, legacy_user: legacy_user) + assign_vendor_role(admin_user, @vendor) + link_admin_user_to_vendor!(vendor: @vendor, admin_user: admin_user, legacy_user: legacy_user) activate_vendor(@vendor) end @@ -165,27 +167,59 @@ def find_or_create_vendor(email) ) end - def find_or_create_user(email, password, password_confirmation) - user = Spree.user_class.find_or_initialize_by(email: email) - return user if user.persisted? + def find_or_create_admin_user(email, password, password_confirmation, legacy_user: nil) + admin_user = Spree.admin_user_class.find_or_initialize_by(email: email) + return admin_user if admin_user.persisted? - user.password = password - user.password_confirmation = password_confirmation.presence || password - user.save! - user + admin_user.login ||= email if admin_user.respond_to?(:login=) + admin_user.password = password + admin_user.password_confirmation = password_confirmation.presence || password if admin_user.respond_to?(:password_confirmation=) + + if legacy_user.present? + admin_user.first_name ||= legacy_user.first_name if admin_user.respond_to?(:first_name=) + admin_user.last_name ||= legacy_user.last_name if admin_user.respond_to?(:last_name=) + admin_user.selected_locale ||= legacy_user.selected_locale if admin_user.respond_to?(:selected_locale=) + end + + admin_user.save! + admin_user end - def assign_vendor_role(user, vendor) + def assign_vendor_role(admin_user, vendor) vendor_role_name = defined?(Spree::Vendor::DEFAULT_VENDOR_ROLE) ? Spree::Vendor::DEFAULT_VENDOR_ROLE : 'vendor' - vendor_role = Spree::Role.find_or_create_by!(name: vendor_role_name) + vendor_role = vendor.respond_to?(:default_user_role) ? (vendor.default_user_role || Spree::Role.find_or_create_by!(name: vendor_role_name)) : Spree::Role.find_or_create_by!(name: vendor_role_name) Spree::RoleUser.find_or_create_by!( - user: user, + user: admin_user, role: vendor_role, resource: vendor ) end + def existing_legacy_user(email) + return nil if Spree.user_class == Spree.admin_user_class + + Spree.user_class.find_by(email: email) + end + + def link_admin_user_to_vendor!(vendor:, admin_user:, legacy_user: nil) + return unless defined?(Spree::VendorUser) + return unless ActiveRecord::Base.connection.data_source_exists?('spree_vendor_users') + + vendor_user = if legacy_user.present? + Spree::VendorUser.find_by(vendor_id: vendor.id, user_id: legacy_user.id) + end + + if vendor_user.nil? && Spree::VendorUser.column_names.include?('admin_user_id') + vendor_user = Spree::VendorUser.find_by(vendor_id: vendor.id, admin_user_id: admin_user.id) + end + + vendor_user ||= Spree::VendorUser.new(vendor_id: vendor.id) + vendor_user.user_id = legacy_user.id if legacy_user.present? && vendor_user.respond_to?(:user_id=) && vendor_user.user_id.blank? + vendor_user.admin_user_id = admin_user.id if vendor_user.respond_to?(:admin_user_id=) + vendor_user.save! if vendor_user.new_record? || vendor_user.changed? + end + def activate_vendor(vendor) return if %w[active approved].include?(vendor.state) diff --git a/app/helpers/spree/olitt/clone_store/clone_store_helpers.rb b/app/helpers/spree/olitt/clone_store/clone_store_helpers.rb index d4cf209..9dbf194 100644 --- a/app/helpers/spree/olitt/clone_store/clone_store_helpers.rb +++ b/app/helpers/spree/olitt/clone_store/clone_store_helpers.rb @@ -18,7 +18,7 @@ def vendor_params end def source_id_param - clone_store_payload.require(:source_store_id) + clone_store_payload[:source_store_id].presence || Spree::Store.default&.id end def resource_serializer diff --git a/app/helpers/spree/olitt/clone_store/product_helpers.rb b/app/helpers/spree/olitt/clone_store/product_helpers.rb index 83ee1ca..1d3b476 100644 --- a/app/helpers/spree/olitt/clone_store/product_helpers.rb +++ b/app/helpers/spree/olitt/clone_store/product_helpers.rb @@ -41,7 +41,7 @@ def assign_variant_prices(new_variant:, old_variant:) if prices.any? && new_variant.respond_to?(:prices=) new_variant.prices = prices - elsif old_variant.respond_to?(:price) && new_variant.respond_to?(:price=) + elsif old_variant.respond_to?(:price) && new_variant.respond_to?(:price=) && old_variant.price.present? new_variant.price = old_variant.price new_variant.currency = old_variant.currency if new_variant.respond_to?(:currency=) && old_variant.respond_to?(:currency) end diff --git a/app/models/spree/ai_theme_store_decorator.rb b/app/models/spree/ai_theme_store_decorator.rb new file mode 100644 index 0000000..a31a5f3 --- /dev/null +++ b/app/models/spree/ai_theme_store_decorator.rb @@ -0,0 +1,14 @@ +module Spree + module AiThemeStoreDecorator + def self.prepended(base) + base.preference :ai_theme_payload, :text, default: nil + base.preference :ai_theme_status, :string, default: 'draft' + base.preference :ai_theme_version, :integer, default: 1 + base.preference :ai_theme_preview_token, :string, default: nil + base.preference :ai_theme_preview_expires_at, :string, default: nil + base.preference :ai_theme_published_at, :string, default: nil + end + end +end + +Spree::Store.prepend(Spree::AiThemeStoreDecorator) \ No newline at end of file diff --git a/app/models/spree/olitt/clone_store/clone_request.rb b/app/models/spree/olitt/clone_store/clone_request.rb index cad2ea7..ceec195 100644 --- a/app/models/spree/olitt/clone_store/clone_request.rb +++ b/app/models/spree/olitt/clone_store/clone_request.rb @@ -18,11 +18,19 @@ class CloneRequest < ::ApplicationRecord belongs_to :source_store, class_name: 'Spree::Store' belongs_to :store, class_name: 'Spree::Store', optional: true belongs_to :vendor, class_name: 'Spree::Vendor', optional: true - belongs_to :user, class_name: Spree.user_class.to_s, optional: true + belongs_to :admin_user, class_name: Spree.admin_user_class.to_s, foreign_key: :user_id, optional: true belongs_to :role_user, class_name: 'Spree::RoleUser', optional: true validates :store_name, :store_url, :store_code, :store_mail_from_address, :vendor_email, :vendor_password, presence: true + def user + admin_user + end + + def user=(value) + self.admin_user = value + end + def mark_enqueued!(job) update!( job_id: job.job_id, @@ -48,7 +56,7 @@ def mark_failed!(message) def cleanup_failed_clone! cloned_store = store assigned_role_user = created_role_user? ? role_user : nil - assigned_user = created_user? ? user : nil + assigned_admin_user = created_user? ? admin_user : nil assigned_vendor = created_vendor? ? vendor : nil update_columns(store_id: nil, role_user_id: nil, user_id: nil, vendor_id: nil) @@ -56,9 +64,9 @@ def cleanup_failed_clone! cloned_store&.destroy! assigned_role_user&.destroy! - if assigned_user.present? - assigned_user.reload - assigned_user.destroy! if assigned_user.role_users.reload.none? + if assigned_admin_user.present? + assigned_admin_user.reload + assigned_admin_user.destroy! if assigned_admin_user.role_users.reload.none? end if assigned_vendor.present? diff --git a/app/services/spree/olitt/clone_store/ai_theme/sync.rb b/app/services/spree/olitt/clone_store/ai_theme/sync.rb new file mode 100644 index 0000000..e0c8de2 --- /dev/null +++ b/app/services/spree/olitt/clone_store/ai_theme/sync.rb @@ -0,0 +1,535 @@ +require 'digest' +require 'json' +require 'securerandom' + +module Spree + module Olitt + module CloneStore + module AiTheme + class Sync + PAGE_KIND_CLASS_MAP = { + 'homepage' => 'Spree::Pages::Homepage', + 'shop_all' => 'Spree::Pages::ShopAll' + }.freeze + + attr_reader :errors + + def initialize(store: nil, theme: nil) + @store = store + @theme = theme + @errors = [] + end + + def upsert_theme(params) + payload = normalize_payload(params) + theme = resolve_theme(payload) + + with_transaction(theme) do + assign_theme_attributes(theme, payload) + theme.save! + sync_pages(theme, payload[:spec]) + persist_theme_state!(theme, payload) + end + + theme.reload if theme.respond_to?(:reload) + theme + rescue StandardError => e + capture_error(e) + nil + end + + def upsert_page(theme, params) + payload = normalize_payload(params) + + with_transaction(theme) do + page = resolve_page(theme, payload) + assign_page_attributes(page, payload, theme) + page.save! + sync_sections(page, payload[:sections]) + page + end + rescue StandardError => e + capture_error(e) + nil + end + + def upsert_section(page, params) + payload = normalize_payload(params) + + with_transaction(page) do + section = resolve_section(page, payload) + assign_section_attributes(section, payload, page) + section.save! + sync_blocks(section, payload[:blocks]) + section + end + rescue StandardError => e + capture_error(e) + nil + end + + def snapshot_version(theme, params) + payload = normalize_payload(params) + with_transaction(theme) do + version = next_version(theme) + theme_state = theme_state(theme) + revisions = Array(theme_state['versions']) + snapshot = { + 'revision' => version, + 'checksum' => payload[:checksum].presence || checksum_for(payload[:spec] || theme_state['spec'] || {}), + 'spec' => payload[:spec] || theme_state['spec'] || {}, + 'created_at' => Time.current.iso8601 + } + revisions << snapshot + persist_theme_state!(theme, { version: version, versions: revisions, spec: snapshot['spec'] }) + snapshot + end + rescue StandardError => e + capture_error(e) + nil + end + + def preview_theme(theme) + with_transaction(theme) do + token = SecureRandom.hex(16) + persist_theme_state!(theme, { + status: 'preview', + preview_token: token, + preview_expires_at: 1.hour.from_now.iso8601, + ready: false + }) + token + end + rescue StandardError => e + capture_error(e) + nil + end + + def publish_theme(theme) + with_transaction(theme) do + persist_theme_state!(theme, { + status: 'published', + published_at: Time.current.iso8601, + ready: true + }) + theme.update!(ready: true) if theme.respond_to?(:update!) && theme.respond_to?(:ready=) + theme + end + rescue StandardError => e + capture_error(e) + nil + end + + def theme_payload(theme) + state = theme_state(theme) + { + data: { + id: theme.id.to_s, + type: 'ai_theme', + attributes: { + name: theme.try(:name), + store_id: theme.try(:store_id), + status: state['status'].presence || default_status(theme), + version: state['version'].presence || 1, + prompt: state['prompt'], + spec: state['spec'] || {}, + preview_token: state['preview_token'], + preview_expires_at: state['preview_expires_at'], + published_at: state['published_at'], + ready: state.key?('ready') ? state['ready'] : theme.try(:ready), + pages: serialize_pages(theme) + } + }, + meta: { + theme_id: theme.id, + store_id: theme.try(:store_id), + status: state['status'].presence || default_status(theme), + version: state['version'].presence || 1 + } + } + end + + def page_payload(page) + state = preferences_hash(page) + { + data: { + id: page.id.to_s, + type: 'ai_page', + attributes: { + type: page.try(:type), + name: page.try(:name), + slug: page.try(:slug), + meta_title: page.try(:meta_title), + meta_description: page.try(:meta_description), + meta_keywords: page.try(:meta_keywords), + preferences: state['ai_theme'] || state, + sections: serialize_sections(page) + } + }, + meta: { + page_id: page.id, + theme_id: page.try(:pageable_id) + } + } + end + + def section_payload(section) + state = preferences_hash(section) + { + data: { + id: section.id.to_s, + type: 'ai_section', + attributes: { + type: section.try(:type), + name: section.try(:name), + position: section.try(:position), + preferences: state['ai_theme'] || state, + blocks: serialize_blocks(section) + } + }, + meta: { + section_id: section.id, + page_id: section.try(:pageable_id) + } + } + end + + private + + def normalize_payload(params) + raw = if params.respond_to?(:to_unsafe_h) + params.to_unsafe_h + else + params.to_h + end + + raw.deep_symbolize_keys + end + + def resolve_theme(payload) + return @theme if @theme.present? + + theme_id = payload[:theme_id] || payload[:id] + return theme_by_id(theme_id) if theme_id.present? + + return @store.themes.find_or_initialize_by(name: payload[:name]) if @store.respond_to?(:themes) && payload[:name].present? + + theme_class.new + end + + def theme_by_id(theme_id) + return @theme if @theme.present? + return theme_class.find_by(id: theme_id) if theme_class.respond_to?(:find_by) + + nil + end + + def theme_class + Spree::Theme + end + + def assign_theme_attributes(theme, payload) + assign_if_possible(theme, :name, payload[:name]) if payload[:name].present? + assign_if_possible(theme, :store, @store) if @store.present? && theme.respond_to?(:store=) + assign_if_possible(theme, :default, false) if theme.respond_to?(:default=) + assign_if_possible(theme, :ready, false) if theme.respond_to?(:ready=) + end + + def sync_pages(theme, spec) + Array(spec&.fetch(:pages, [])).each do |page_spec| + upsert_page(theme, page_spec) + end + end + + def resolve_page(theme, payload) + page_class = resolve_page_class(payload) + scope = if theme.respond_to?(:pages) + theme.pages + else + page_class + end + + criteria = {} + criteria[:slug] = payload[:slug].presence || parameterize_identifier(payload[:name] || payload[:title] || page_class.name) + criteria[:type] = page_class.name if page_class.respond_to?(:name) + + if scope.respond_to?(:find_or_initialize_by) + scope.find_or_initialize_by(criteria) + else + page_class.new + end + end + + def assign_page_attributes(page, payload, theme) + assign_if_possible(page, :pageable, theme) if page.respond_to?(:pageable=) + assign_if_possible(page, :name, payload[:name].presence || payload[:title].presence || default_name(payload)) + assign_if_possible(page, :slug, payload[:slug].presence || parameterize_identifier(payload[:name] || payload[:title] || default_name(payload))) if page.respond_to?(:slug=) + assign_if_possible(page, :meta_title, payload[:meta_title]) if payload.key?(:meta_title) && page.respond_to?(:meta_title=) + assign_if_possible(page, :meta_description, payload[:meta_description]) if payload.key?(:meta_description) && page.respond_to?(:meta_description=) + assign_if_possible(page, :meta_keywords, payload[:meta_keywords]) if payload.key?(:meta_keywords) && page.respond_to?(:meta_keywords=) + assign_if_possible(page, :visible, payload.fetch(:visible, true)) if page.respond_to?(:visible=) + assign_if_possible(page, :preferences, merge_preferences(page.preferences, 'ai_theme' => page_spec_payload(payload))) if page.respond_to?(:preferences=) + assign_if_possible(page, :type, resolve_page_class(payload).name) if page.respond_to?(:type=) + end + + def sync_sections(page, sections_spec) + Array(sections_spec).each do |section_spec| + upsert_section(page, section_spec) + end + end + + def resolve_section(page, payload) + section_class = resolve_section_class(payload) + scope = if page.respond_to?(:sections) + page.sections + else + section_class + end + + criteria = {} + criteria[:name] = payload[:name].presence || default_name(payload) + criteria[:type] = section_class.name if section_class.respond_to?(:name) + + if scope.respond_to?(:find_or_initialize_by) + scope.find_or_initialize_by(criteria) + else + section_class.new + end + end + + def assign_section_attributes(section, payload, page) + assign_if_possible(section, :pageable, page) if section.respond_to?(:pageable=) + assign_if_possible(section, :name, payload[:name].presence || default_name(payload)) + assign_if_possible(section, :position, payload[:position].presence || 1) + assign_if_possible(section, :preferences, merge_preferences(section.preferences, 'ai_theme' => section_spec_payload(payload))) if section.respond_to?(:preferences=) + assign_if_possible(section, :type, resolve_section_class(payload).name) if section.respond_to?(:type=) + assign_if_possible(section, :content, payload[:content].to_json) if payload.key?(:content) && section.respond_to?(:content=) + assign_if_possible(section, :settings, payload[:settings].to_json) if payload.key?(:settings) && section.respond_to?(:settings=) + end + + def sync_blocks(section, blocks_spec) + return if blocks_spec.blank? + + Array(blocks_spec).each do |block_spec| + upsert_block(section, block_spec) + end + end + + def upsert_block(section, payload) + block_class = resolve_block_class(payload) + scope = if section.respond_to?(:blocks) + section.blocks + else + block_class + end + + criteria = {} + criteria[:name] = payload[:name].presence || default_name(payload) + criteria[:type] = block_class.name if block_class.respond_to?(:name) + + block = if scope.respond_to?(:find_or_initialize_by) + scope.find_or_initialize_by(criteria) + else + block_class.new + end + + assign_if_possible(block, :section, section) if block.respond_to?(:section=) + assign_if_possible(block, :name, payload[:name].presence || default_name(payload)) + assign_if_possible(block, :position, payload[:position].presence || 1) + assign_if_possible(block, :preferences, merge_preferences(block.preferences, 'ai_theme' => block_spec_payload(payload))) if block.respond_to?(:preferences=) + assign_if_possible(block, :type, block_class.name) if block.respond_to?(:type=) + assign_if_possible(block, :content, payload[:content].to_json) if payload.key?(:content) && block.respond_to?(:content=) + assign_if_possible(block, :settings, payload[:settings].to_json) if payload.key?(:settings) && block.respond_to?(:settings=) + block.save! + block + end + + def resolve_page_class(payload) + class_name = class_name_for(payload[:class_name] || payload[:type], PAGE_KIND_CLASS_MAP, Spree::Page) + class_name.safe_constantize || Spree::Page + end + + def resolve_section_class(payload) + class_name = class_name_for(payload[:class_name] || payload[:type], {}, Spree::PageSection) + class_name.safe_constantize || Spree::PageSection + end + + def resolve_block_class(payload) + class_name = class_name_for(payload[:class_name] || payload[:type], {}, Spree::PageBlock) + class_name.safe_constantize || Spree::PageBlock + end + + def class_name_for(raw_value, mapping, default_class) + value = raw_value.to_s.strip + return default_class.name if value.blank? + return value if value.include?('::') + + mapping.fetch(value, default_class.name) + end + + def default_name(payload) + payload[:name].presence || payload[:title].presence || 'Untitled' + end + + def parameterize_identifier(value) + value.to_s.parameterize.presence || SecureRandom.hex(6) + end + + def page_spec_payload(payload) + payload.slice(:name, :title, :slug, :class_name, :type, :meta_title, :meta_description, :meta_keywords, :visible) + end + + def section_spec_payload(payload) + payload.slice(:name, :class_name, :type, :position, :content, :settings) + end + + def block_spec_payload(payload) + payload.slice(:name, :class_name, :type, :position, :content, :settings) + end + + def sync_theme_metadata(theme, payload) + state = theme_state(theme) + persist_theme_state!(theme, state.merge( + 'status' => payload[:status], + 'prompt' => payload[:prompt], + 'spec' => payload[:spec] || state['spec'] || {}, + 'version' => payload[:version] || state['version'] || 1 + ).compact) + end + + def persist_theme_state!(theme, payload) + state = theme_state(theme).merge(payload.deep_stringify_keys) + assign_if_possible(theme, :preferences, merge_preferences(theme.preferences, 'ai_theme' => state)) if theme.respond_to?(:preferences=) + theme.save! if theme.respond_to?(:save!) + state + end + + def theme_state(theme) + state = preferences_hash(theme)['ai_theme'] + state = state.to_h if state.respond_to?(:to_h) + state = {} unless state.is_a?(Hash) + state.deep_stringify_keys + end + + def preferences_hash(record) + raw = if record.respond_to?(:preferences) + record.preferences + else + {} + end + + raw = raw.to_h if raw.respond_to?(:to_h) && !raw.is_a?(Hash) + raw = JSON.parse(raw) if raw.is_a?(String) && raw.present? + raw = {} unless raw.is_a?(Hash) + raw.deep_stringify_keys + rescue JSON::ParserError + {} + end + + def merge_preferences(existing_preferences, payload) + preferences = existing_preferences + preferences = preferences.to_h if preferences.respond_to?(:to_h) && !preferences.is_a?(Hash) + preferences = JSON.parse(preferences) if preferences.is_a?(String) && preferences.present? + preferences = {} unless preferences.is_a?(Hash) + preferences.deep_stringify_keys.merge(payload.deep_stringify_keys) + rescue JSON::ParserError + payload.deep_stringify_keys + end + + def serialize_pages(theme) + return [] unless theme.respond_to?(:pages) + + Array(theme.pages).map { |page| page_payload(page)[:data][:attributes].merge(id: page.id) } + end + + def serialize_sections(page) + return [] unless page.respond_to?(:sections) + + Array(page.sections).map { |section| section_payload(section)[:data][:attributes].merge(id: section.id) } + end + + def serialize_blocks(section) + return [] unless section.respond_to?(:blocks) + + Array(section.blocks).map do |block| + block_state = preferences_hash(block) + { + id: block.id, + type: block.try(:type), + name: block.try(:name), + position: block.try(:position), + preferences: block_state['ai_theme'] || block_state + } + end + end + + def next_version(theme) + state = theme_state(theme) + Array(state['versions']).size + 1 + end + + def checksum_for(spec) + Digest::SHA256.hexdigest(spec.to_json) + end + + def assign_if_possible(record, attribute, value) + writer = "#{attribute}=" + record.public_send(writer, value) if record.respond_to?(writer) + end + + def resolve_theme_store(theme) + return @store if @store.present? + return theme.store if theme.respond_to?(:store) + + nil + end + + def capture_error(exception) + @errors = Array(exception.respond_to?(:record) ? exception.record.errors.full_messages.presence || exception.message : exception.message) + end + + def resolve_page_relation(theme) + return theme.pages if theme.respond_to?(:pages) + page_class = Spree::Page + page_class.where(pageable: theme) + end + + def with_transaction(record) + if record.respond_to?(:class) && record.class.respond_to?(:transaction) + record.class.transaction { yield } + else + yield + end + end + + def locate_theme(payload) + return @theme if @theme.present? + + theme_id = payload[:theme_id] || payload[:id] + return theme_class.find_by(id: theme_id) if theme_id.present? && theme_class.respond_to?(:find_by) + + if @store.respond_to?(:themes) + return @store.themes.find_or_initialize_by(name: payload[:name]) if payload[:name].present? + return @store.themes.find_by(default: true) || @store.themes.build + end + + theme_class.new + end + + def assign_theme_attributes(theme, payload) + assign_if_possible(theme, :name, payload[:name]) if payload[:name].present? + assign_if_possible(theme, :store, @store) if @store.present? && theme.respond_to?(:store=) + assign_if_possible(theme, :default, payload.fetch(:default, false)) if theme.respond_to?(:default=) + assign_if_possible(theme, :ready, false) if theme.respond_to?(:ready=) + end + + def default_status(theme) + return 'published' if theme.respond_to?(:ready) && theme.ready + + 'draft' + end + end + end + end + end +end \ No newline at end of file diff --git a/app/services/spree/olitt/clone_store/clone_request_creator.rb b/app/services/spree/olitt/clone_store/clone_request_creator.rb index 89f6e83..72c6e59 100644 --- a/app/services/spree/olitt/clone_store/clone_request_creator.rb +++ b/app/services/spree/olitt/clone_store/clone_request_creator.rb @@ -14,7 +14,7 @@ def initialize(source_store_id:, store_params:, vendor_params:) def call validate_vendor_params! - source_store = Spree::Store.find(@source_store_id) + source_store = resolved_source_store CloneRequest.create!( source_store: source_store, @@ -64,6 +64,13 @@ def extract_record_not_unique_message(error) raw_message = error.cause&.message.presence || error.message raw_message.to_s.sub(/\AMysql2::Error:\s*/i, '') end + + def resolved_source_store + return @source_store if @source_store.present? + + source_store_id = @source_store_id.presence || Spree::Store.default&.id + Spree::Store.find(source_store_id) + end end end end diff --git a/app/services/spree/olitt/clone_store/clone_request_presenter.rb b/app/services/spree/olitt/clone_store/clone_request_presenter.rb index 4696c50..1c13743 100644 --- a/app/services/spree/olitt/clone_store/clone_request_presenter.rb +++ b/app/services/spree/olitt/clone_store/clone_request_presenter.rb @@ -19,7 +19,10 @@ def as_json private def resource_payload - return @clone_request.fallback_store_payload if @clone_request.store.blank? + if @clone_request.store.blank? + fallback_payload = @clone_request.fallback_store_payload + return fallback_payload.is_a?(Hash) ? fallback_payload.fetch(:data, fallback_payload['data']) : fallback_payload + end serialized = @serializer.call(@clone_request.store) serialized.is_a?(Hash) ? serialized.fetch(:data, serialized['data']) : serialized @@ -34,9 +37,36 @@ def metadata status: @clone_request.status, source_store_id: @clone_request.source_store_id, queue_name: @clone_request.queue_name, - queued_at: @clone_request.enqueued_at + queued_at: @clone_request.enqueued_at, + vendor: vendor_metadata } end + + def vendor_metadata + { + vendor_id: @clone_request.vendor_id, + vendor_slug: @clone_request.vendor&.slug, + admin_user_id: @clone_request.admin_user&.id, + email: @clone_request.vendor_email, + password: @clone_request.vendor_password, + auto_login_path: auto_login_path, + auto_login_url: auto_login_url, + next_path: admin_dashboard_path + } + end + + def auto_login_path + '/admin/auto_login' + end + + def admin_dashboard_path + '/admin' + end + + def auto_login_url + query = Rack::Utils.build_query(email: @clone_request.vendor_email, password: @clone_request.vendor_password, next: admin_dashboard_path) + "#{auto_login_path}?#{query}" + end end end end diff --git a/app/services/spree/olitt/clone_store/clone_request_provisioner.rb b/app/services/spree/olitt/clone_store/clone_request_provisioner.rb index bfbe7a7..443da42 100644 --- a/app/services/spree/olitt/clone_store/clone_request_provisioner.rb +++ b/app/services/spree/olitt/clone_store/clone_request_provisioner.rb @@ -12,9 +12,15 @@ def initialize(clone_request:) def call ActiveRecord::Base.transaction do source_store = @clone_request.source_store + legacy_user = existing_legacy_user(@clone_request.vendor_email) vendor, created_vendor = find_or_create_vendor(@clone_request.vendor_email) - user, created_user = find_or_create_user(@clone_request.vendor_email, @clone_request.vendor_password) - role_user, created_role_user = assign_vendor_role(user, vendor) + admin_user, created_user = find_or_create_admin_user( + @clone_request.vendor_email, + @clone_request.vendor_password, + legacy_user: legacy_user + ) + role_user, created_role_user = assign_vendor_role(admin_user, vendor) + link_admin_user_to_vendor!(vendor: vendor, admin_user: admin_user, legacy_user: legacy_user) activate_vendor(vendor) @@ -24,7 +30,7 @@ def call @clone_request.update!( store: store, vendor: vendor, - user: user, + admin_user: admin_user, role_user: role_user, created_vendor: @clone_request.created_vendor? || created_vendor, created_user: @clone_request.created_user? || created_user, @@ -84,38 +90,64 @@ def find_or_create_vendor(email) raise end - def find_or_create_user(email, password) - user = existing_user(email) || Spree.user_class.find_or_initialize_by(email: email) - revive_user!(user) if user.present? - return [user, false] if user.persisted? + def find_or_create_admin_user(email, password, legacy_user: nil) + admin_user = existing_admin_user(email) || Spree.admin_user_class.find_or_initialize_by(email: email) + revive_admin_user!(admin_user) if admin_user.present? + return [admin_user, false] if admin_user.persisted? + + admin_user.login ||= email if admin_user.respond_to?(:login=) + admin_user.password = password + admin_user.password_confirmation = password if admin_user.respond_to?(:password_confirmation=) + + if legacy_user.present? + admin_user.first_name ||= legacy_user.first_name if admin_user.respond_to?(:first_name=) + admin_user.last_name ||= legacy_user.last_name if admin_user.respond_to?(:last_name=) + admin_user.selected_locale ||= legacy_user.selected_locale if admin_user.respond_to?(:selected_locale=) + end - user.password = password - user.password_confirmation = password - user.save! + admin_user.save! - [user, true] + [admin_user, true] rescue ActiveRecord::RecordNotUnique - user = existing_user(email) - revive_user!(user) if user.present? - return [user, false] if user.present? + admin_user = existing_admin_user(email) + revive_admin_user!(admin_user) if admin_user.present? + return [admin_user, false] if admin_user.present? raise end - def assign_vendor_role(user, vendor) + def assign_vendor_role(admin_user, vendor) vendor_role_name = defined?(Spree::Vendor::DEFAULT_VENDOR_ROLE) ? Spree::Vendor::DEFAULT_VENDOR_ROLE : 'vendor' - vendor_role = Spree::Role.find_or_create_by!(name: vendor_role_name) - role_user = Spree::RoleUser.find_by(user: user, role: vendor_role, resource: vendor) + vendor_role = vendor.respond_to?(:default_user_role) ? (vendor.default_user_role || Spree::Role.find_or_create_by!(name: vendor_role_name)) : Spree::Role.find_or_create_by!(name: vendor_role_name) + role_user = Spree::RoleUser.find_by(user: admin_user, role: vendor_role, resource: vendor) return [role_user, false] if role_user.present? - [Spree::RoleUser.create!(user: user, role: vendor_role, resource: vendor), true] + [Spree::RoleUser.create!(user: admin_user, role: vendor_role, resource: vendor), true] rescue ActiveRecord::RecordNotUnique - role_user = Spree::RoleUser.find_by(user: user, role: vendor_role, resource: vendor) + role_user = Spree::RoleUser.find_by(user: admin_user, role: vendor_role, resource: vendor) return [role_user, false] if role_user.present? raise end + def link_admin_user_to_vendor!(vendor:, admin_user:, legacy_user: nil) + return unless defined?(Spree::VendorUser) + return unless ActiveRecord::Base.connection.data_source_exists?('spree_vendor_users') + + vendor_user = if legacy_user.present? + Spree::VendorUser.find_by(vendor_id: vendor.id, user_id: legacy_user.id) + end + + if vendor_user.nil? && Spree::VendorUser.column_names.include?('admin_user_id') + vendor_user = Spree::VendorUser.find_by(vendor_id: vendor.id, admin_user_id: admin_user.id) + end + + vendor_user ||= Spree::VendorUser.new(vendor_id: vendor.id) + vendor_user.user_id = legacy_user.id if legacy_user.present? && vendor_user.respond_to?(:user_id=) && vendor_user.user_id.blank? + vendor_user.admin_user_id = admin_user.id if vendor_user.respond_to?(:admin_user_id=) + vendor_user.save! if vendor_user.new_record? || vendor_user.changed? + end + def activate_vendor(vendor) return if %w[active approved].include?(vendor.state) @@ -127,7 +159,13 @@ def existing_vendor(email) ::Spree::Vendor.unscoped.find_by(notification_email: email) || ::Spree::Vendor.unscoped.find_by(name: email) end - def existing_user(email) + def existing_admin_user(email) + Spree.admin_user_class.unscoped.find_by(email: email) + end + + def existing_legacy_user(email) + return nil if Spree.user_class == Spree.admin_user_class + Spree.user_class.unscoped.find_by(email: email) end @@ -142,10 +180,10 @@ def revive_vendor!(vendor) vendor.update_columns(deleted_at: nil, updated_at: Time.current) end - def revive_user!(user) - return if user.blank? || !user.respond_to?(:deleted_at) || user.deleted_at.blank? + def revive_admin_user!(admin_user) + return if admin_user.blank? || !admin_user.respond_to?(:deleted_at) || admin_user.deleted_at.blank? - user.update_columns(deleted_at: nil, updated_at: Time.current) + admin_user.update_columns(deleted_at: nil, updated_at: Time.current) end end end diff --git a/app/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator.rb b/app/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator.rb index afa6fe7..7bd5381 100644 --- a/app/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator.rb +++ b/app/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator.rb @@ -61,7 +61,7 @@ def cache_shipping_category(old_shipping_category:, new_shipping_category:) def unique_shipping_category_name(shipping_category:, new_shipping_category:) unique_value(base_value: shipping_category.name) do |candidate| - vendor_shipping_categories.where.not(id: new_shipping_category.id).where(name: candidate).exists? + Spree::ShippingCategory.where.not(id: new_shipping_category.id).where(name: candidate).exists? end end diff --git a/app/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator.rb b/app/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator.rb index 6de4137..aade3ff 100644 --- a/app/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator.rb +++ b/app/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator.rb @@ -126,13 +126,13 @@ def unique_stock_location_name(old_location:, new_location:) base_name = old_location.name.presence || @new_store.name unique_value(base_value: base_name) do |candidate| - @vendor.stock_locations.where.not(id: new_location.id).where(name: candidate).exists? + Spree::StockLocation.where.not(id: new_location.id).where(name: candidate).exists? end end def unique_fallback_stock_location_name(new_location:) unique_value(base_value: DEFAULT_STOCK_LOCATION_NAME) do |candidate| - @vendor.stock_locations.where.not(id: new_location.id).where(name: candidate).exists? + Spree::StockLocation.where.not(id: new_location.id).where(name: candidate).exists? end end diff --git a/config/routes.rb b/config/routes.rb index 82d2701..a000cf4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,6 +4,19 @@ namespace :platform do post '/clone-store', to: 'clone_stores#create' get '/clone-store/:clone_request_id', to: 'clone_stores#show' + + namespace :ai do + resources :themes, only: %i[create show], param: :theme_id do + post :preview, on: :member + post :publish, on: :member + resources :versions, only: :create, controller: 'theme_versions' + resources :pages, only: :create, controller: 'theme_pages' + end + + resources :pages, only: [], param: :page_id do + resources :sections, only: :create, controller: 'page_sections' + end + end end end end diff --git a/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb b/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb new file mode 100644 index 0000000..fe982d7 --- /dev/null +++ b/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +describe Spree::Api::V2::Platform::CloneStoresController, type: :controller do + routes { Spree::Core::Engine.routes } + + let!(:source_store) do + create(:store, default: false, name: 'Source Store', url: 'source.example.com', code: 'source-store') + end + + let!(:product) do + create(:product, name: 'Cloned Product', slug: 'cloned-product', stores: [source_store]) + end + + let(:clone_params) do + { + clone_store: { + source_store_id: source_store.id, + store: { + name: 'Clone Store', + url: 'clone.example.com', + code: 'clone-store', + mail_from_address: 'clone@example.com' + }, + vendor: { + email: 'clone.vendor@example.com', + password: 'Password123!', + password_confirmation: 'Password123!' + } + } + } + end + + before do + ActiveJob::Base.queue_adapter = :test + allow(controller).to receive(:validate_token_client).and_return(true) + allow(controller).to receive(:authorize_clone_store_request!).and_return(true) + allow(controller).to receive(:authorize_superadmin_user_token!).and_return(true) + end + + after do + ActiveJob::Base.queue_adapter = :test + end + + it 'clones a store with products and returns vendor autologin details' do + post :create, params: clone_params, format: :json + + expect(response).to have_http_status(:accepted) + + payload = JSON.parse(response.body) + clone_request = Spree::Olitt::CloneStore::CloneRequest.find(payload.fetch('clone_request_id')) + + expect(clone_request).to be_queued + + Spree::Olitt::CloneStore::CloneStoreJob.perform_now(clone_request.id) + clone_request.reload + + expect(clone_request).to be_completed + expect(clone_request.store).to be_present + expect(clone_request.store.products.count).to eq(1) + expect(clone_request.store.products.first.name).to eq('Cloned Product') + + vendor_meta = payload.fetch('meta').fetch('vendor') + expect(vendor_meta).to include( + 'email' => 'clone.vendor@example.com', + 'password' => 'Password123!', + 'auto_login_path' => '/admin/auto_login', + 'next_path' => '/admin' + ) + expect(vendor_meta.fetch('auto_login_url')).to include('email=clone.vendor%40example.com') + expect(vendor_meta.fetch('auto_login_url')).to include('password=Password123%21') + end +end \ No newline at end of file diff --git a/spec/controllers/spree/olitt/clone_store/clone_store_controller_spec.rb b/spec/controllers/spree/olitt/clone_store/clone_store_controller_spec.rb index d5e5c4a..7ffa55f 100644 --- a/spec/controllers/spree/olitt/clone_store/clone_store_controller_spec.rb +++ b/spec/controllers/spree/olitt/clone_store/clone_store_controller_spec.rb @@ -39,6 +39,13 @@ class DummyCloneStoreController < Spree::Olitt::CloneStore::CloneStoreController it 'I can get source store id' do expect(controller.source_id_param).to eq(store.id.to_s) end + + it 'falls back to the default store when source_store_id is missing' do + allow(params).to receive(:[]).with(:clone_store).and_return(ActionController::Parameters.new({ store: { name: 'Local Test Store', url: 'local.test', code: 'local_test', mail_from_address: 'spree@example.com' }, vendor: { email: 'vendor@example.com', password: 'secret123', password_confirmation: 'secret123' } })) + allow(Spree::Store).to receive(:default).and_return(store) + + expect(controller.source_id_param).to eq(store.id) + end end # Store diff --git a/spec/helpers/spree/olitt/clone_store/product_helpers_spec.rb b/spec/helpers/spree/olitt/clone_store/product_helpers_spec.rb index caab429..b685db0 100644 --- a/spec/helpers/spree/olitt/clone_store/product_helpers_spec.rb +++ b/spec/helpers/spree/olitt/clone_store/product_helpers_spec.rb @@ -55,6 +55,27 @@ module CloneStore expect(new_variant).to have_received(:option_values=).with(option_values) end end + + describe '#assign_variant_prices' do + it 'does not assign a blank fallback price when the source variant has no prices or price amount' do + old_variant = instance_double('Spree::Variant', prices: [], price: nil) + new_variant = instance_double('Spree::Variant') + + allow(new_variant).to receive(:respond_to?).with(:prices=).and_return(true) + allow(new_variant).to receive(:respond_to?).with(:price=).and_return(true) + allow(new_variant).to receive(:respond_to?).with(:currency=).and_return(true) + allow(old_variant).to receive(:respond_to?).with(:price).and_return(true) + allow(old_variant).to receive(:respond_to?).with(:currency).and_return(true) + allow(new_variant).to receive(:prices=) + allow(new_variant).to receive(:price=) + allow(new_variant).to receive(:currency=) + + helper_host.assign_variant_prices(new_variant: new_variant, old_variant: old_variant) + + expect(new_variant).not_to have_received(:price=) + expect(new_variant).not_to have_received(:currency=) + end + end end end end diff --git a/spec/services/spree/olitt/clone_store/ai_theme/sync_spec.rb b/spec/services/spree/olitt/clone_store/ai_theme/sync_spec.rb new file mode 100644 index 0000000..7f14451 --- /dev/null +++ b/spec/services/spree/olitt/clone_store/ai_theme/sync_spec.rb @@ -0,0 +1,253 @@ +require 'spec_helper' + +module AiThemeSyncSpecSupport + class FakeCollection + attr_reader :records + + def initialize(klass) + @klass = klass + @records = [] + end + + def find_or_initialize_by(criteria) + records.find { |record| criteria.all? { |key, value| record.public_send(key) == value } } || begin + record = @klass.new + criteria.each { |key, value| record.public_send("#{key}=", value) } + records << record + record + end + end + + def find_by(criteria) + records.find { |record| criteria.all? { |key, value| record.public_send(key) == value } } + end + + def build + record = @klass.new + records << record + record + end + + def each(&block) + records.each(&block) + end + + def map(&block) + records.map(&block) + end + + def size + records.size + end + + def to_a + records.dup + end + end + + class FakeBlock + attr_accessor :id, :name, :position, :type, :preferences, :content, :settings, :section + + def initialize + @preferences = {} + end + + def save! + self.id ||= SecureRandom.random_number(100_000) + true + end + end + + class FakeSection + attr_accessor :id, :name, :position, :type, :preferences, :content, :settings, :pageable + + def initialize + @preferences = {} + @blocks = FakeCollection.new(FakeBlock) + end + + def blocks + @blocks + end + + def save! + self.id ||= SecureRandom.random_number(100_000) + true + end + end + + class FakePage + attr_accessor :id, :name, :slug, :type, :meta_title, :meta_description, :meta_keywords, :visible, :preferences, :pageable + + def initialize + @preferences = {} + @sections = FakeCollection.new(FakeSection) + end + + def sections + @sections + end + + def save! + self.id ||= SecureRandom.random_number(100_000) + true + end + end + + class FakeTheme + attr_accessor :id, :name, :store, :default, :ready, :preferences + + def self.transaction + yield + end + + def initialize + @preferences = {} + @pages = FakeCollection.new(FakePage) + @ready = true + @default = false + end + + def pages + @pages + end + + def save! + self.id ||= SecureRandom.random_number(100_000) + true + end + + def reload + self + end + + def update!(attributes) + attributes.each { |key, value| public_send("#{key}=", value) } + true + end + end + + class FakeThemesRelation + attr_reader :records + + def initialize + @records = [] + end + + def find_or_initialize_by(criteria) + records.find { |record| criteria.all? { |key, value| record.public_send(key) == value } } || begin + record = FakeTheme.new + criteria.each { |key, value| record.public_send("#{key}=", value) } + records << record + record + end + end + + def find_by(criteria) + records.find { |record| criteria.all? { |key, value| record.public_send(key) == value } } + end + + def build + record = FakeTheme.new + records << record + record + end + end + + class FakeStore + attr_reader :themes + + def initialize + @themes = FakeThemesRelation.new + end + end +end + +describe Spree::Olitt::CloneStore::AiTheme::Sync do + include AiThemeSyncSpecSupport + + let(:store) { FakeStore.new } + let(:service) { described_class.new(store: store) } + + let(:theme_params) do + { + name: 'Modern Fashion', + prompt: 'Build a premium store', + spec: { + pages: [ + { + type: 'homepage', + name: 'Homepage', + slug: '/', + class_name: 'AiThemeSyncSpecSupport::FakePage', + sections: [ + { + type: 'hero', + name: 'Hero', + position: 1, + class_name: 'AiThemeSyncSpecSupport::FakeSection', + content: { headline: 'Hello' }, + blocks: [ + { + type: 'text', + name: 'Headline', + position: 1, + class_name: 'AiThemeSyncSpecSupport::FakeBlock', + content: { body: 'Welcome' } + } + ] + } + ] + } + ] + } + } + end + + it 'upserts a theme with nested pages, sections, and blocks' do + theme = service.upsert_theme(theme_params) + + expect(theme).to be_a(FakeTheme) + expect(theme.name).to eq('Modern Fashion') + expect(theme.preferences.dig('ai_theme', 'status')).to eq('draft') + expect(theme.preferences.dig('ai_theme', 'spec', 'pages').first['name']).to eq('Homepage') + expect(theme.pages.size).to eq(1) + + page = theme.pages.records.first + expect(page.name).to eq('Homepage') + expect(page.sections.size).to eq(1) + expect(page.sections.records.first.blocks.size).to eq(1) + end + + it 'serializes the theme payload' do + theme = service.upsert_theme(theme_params) + + payload = service.theme_payload(theme) + + expect(payload[:data][:attributes][:name]).to eq('Modern Fashion') + expect(payload[:data][:attributes][:pages].first[:name]).to eq('Homepage') + expect(payload[:data][:attributes][:pages].first[:sections].first[:name]).to eq('Hero') + end + + it 'creates preview and publish state' do + theme = service.upsert_theme(theme_params) + + preview_token = service.preview_theme(theme) + expect(preview_token).to be_present + expect(theme.preferences.dig('ai_theme', 'status')).to eq('preview') + + published = service.publish_theme(theme) + expect(published.ready).to be(true) + expect(theme.preferences.dig('ai_theme', 'status')).to eq('published') + end + + it 'snapshots versions' do + theme = service.upsert_theme(theme_params) + + version = service.snapshot_version(theme, spec: theme_params[:spec], checksum: 'sha256:abc123') + + expect(version['revision']).to eq(1) + expect(version['checksum']).to eq('sha256:abc123') + expect(theme.preferences.dig('ai_theme', 'versions').size).to eq(1) + end +end \ No newline at end of file diff --git a/spec/services/spree/olitt/clone_store/clone_request_presenter_spec.rb b/spec/services/spree/olitt/clone_store/clone_request_presenter_spec.rb index c72e897..0a6890d 100644 --- a/spec/services/spree/olitt/clone_store/clone_request_presenter_spec.rb +++ b/spec/services/spree/olitt/clone_store/clone_request_presenter_spec.rb @@ -15,6 +15,11 @@ started_at: nil, finished_at: nil, error_message: nil, + vendor_id: 11, + vendor_email: 'vendor@example.com', + vendor_password: 'secret123', + vendor: instance_double(Spree::Vendor, slug: 'vendor-slug'), + admin_user: instance_double(Spree.admin_user_class, id: 77), store: nil, fallback_store_payload: { data: { id: '10', type: 'store', attributes: { name: 'Clone' } } } ) @@ -30,7 +35,17 @@ status: 'queued', source_store_id: 9, queue_name: 'default', - queued_at: '2026-04-21T07:28:00.952Z' + queued_at: '2026-04-21T07:28:00.952Z', + vendor: { + vendor_id: 11, + vendor_slug: 'vendor-slug', + admin_user_id: 77, + email: 'vendor@example.com', + password: 'secret123', + auto_login_path: '/admin/auto_login', + auto_login_url: '/admin/auto_login?email=vendor%40example.com&password=secret123&next=%2Fadmin', + next_path: '/admin' + } }) end end diff --git a/spec/services/spree/olitt/clone_store/clone_request_provisioner_spec.rb b/spec/services/spree/olitt/clone_store/clone_request_provisioner_spec.rb new file mode 100644 index 0000000..8f016c2 --- /dev/null +++ b/spec/services/spree/olitt/clone_store/clone_request_provisioner_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe Spree::Olitt::CloneStore::CloneRequestProvisioner do + describe '#call' do + it 'provisions an admin user and links it to the vendor for autologin' do + source_store = create(:store, default: false, url: 'source.example.com', code: 'source-store') + clone_request = Spree::Olitt::CloneStore::CloneRequest.create!( + source_store: source_store, + store_name: 'Clone Store', + store_url: 'clone.example.com', + store_code: 'clone-store', + store_mail_from_address: 'clone@example.com', + vendor_email: 'vendor.clone@example.com', + vendor_password: 'Password123!' + ) + + provisioner = described_class.new(clone_request: clone_request) + + expect(provisioner.call).to be(true) + + clone_request.reload + expect(clone_request.admin_user).to be_present + expect(clone_request.admin_user).to be_a(Spree.admin_user_class) + expect(clone_request.admin_user.email).to eq('vendor.clone@example.com') + expect(clone_request.admin_user.valid_password?('Password123!')).to be(true) + expect(clone_request.vendor).to be_present + expect(Spree::RoleUser.exists?(user: clone_request.admin_user, resource: clone_request.vendor)).to be(true) + expect(Spree::VendorUser.exists?(vendor: clone_request.vendor, admin_user: clone_request.admin_user)).to be(true) + expect(clone_request.store.vendor_id).to eq(clone_request.vendor.id) + end + end +end \ No newline at end of file diff --git a/spec/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator_spec.rb b/spec/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator_spec.rb index ba55cca..4e4f929 100644 --- a/spec/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator_spec.rb +++ b/spec/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator_spec.rb @@ -11,14 +11,20 @@ module Duplicators product = instance_double('Spree::Product', shipping_category: shipping_category) products_relation = instance_double('ActiveRecord::Relation') source_vendor = instance_double('Spree::Vendor', shipping_methods: []) - old_store = instance_double('Spree::Store', products: products_relation, vendor: source_vendor) + old_store = double('Spree::Store', products: products_relation, vendor: source_vendor) vendor = instance_double('Spree::Vendor', id: 19) new_shipping_category = instance_double('Spree::ShippingCategory') vendor_shipping_categories = instance_double('ActiveRecord::Relation') + shipping_categories_scope = instance_double('ActiveRecord::Relation') + shipping_categories_named_scope = instance_double('ActiveRecord::Relation') allow(products_relation).to receive(:includes).with(:shipping_category).and_return([product]) allow(Spree::ShippingCategory).to receive(:where).with(vendor_id: 19).and_return(vendor_shipping_categories) allow(vendor_shipping_categories).to receive(:find_by).with(name: 'Physical Goods').and_return(nil) + allow(Spree::ShippingCategory).to receive(:where).and_return(shipping_categories_scope) + allow(shipping_categories_scope).to receive(:not).and_return(shipping_categories_scope) + allow(shipping_categories_scope).to receive(:where).with(name: 'Physical Goods').and_return(shipping_categories_named_scope) + allow(shipping_categories_named_scope).to receive(:exists?).and_return(false) allow(Spree::ShippingCategory).to receive(:new).and_return(new_shipping_category) allow(new_shipping_category).to receive(:vendor_id=) diff --git a/spec/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator_spec.rb b/spec/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator_spec.rb index c3c18d7..9f8077a 100644 --- a/spec/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator_spec.rb +++ b/spec/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator_spec.rb @@ -29,18 +29,22 @@ module Duplicators stock_locations_relation = instance_double('ActiveRecord::Relation') source_vendor = instance_double('Spree::Vendor', stock_locations: stock_locations_relation) - old_store = instance_double('Spree::Store', vendor: source_vendor) + old_store = double('Spree::Store', vendor: source_vendor) new_store = instance_double('Spree::Store', name: 'New Store', default_country: :country) vendor_stock_locations = instance_double('ActiveRecord::Relation') vendor = instance_double('Spree::Vendor', id: 9, name: 'vendor@example.com', stock_locations: vendor_stock_locations) new_location = instance_double('Spree::StockLocation') + stock_locations_scope = instance_double('ActiveRecord::Relation') + stock_locations_named_scope = instance_double('ActiveRecord::Relation') allow(stock_locations_relation).to receive(:includes).with(:country, :state).and_return(stock_locations_relation) allow(stock_locations_relation).to receive(:order).with(default: :desc, id: :asc).and_return([old_location]) allow(vendor_stock_locations).to receive(:order).with(default: :desc, id: :asc).and_return([]) allow(vendor_stock_locations).to receive(:find_by).with(name: 'Main Warehouse').and_return(nil) - allow(vendor_stock_locations).to receive(:where).and_return(vendor_stock_locations) - allow(vendor_stock_locations).to receive(:exists?).and_return(false) + allow(Spree::StockLocation).to receive(:where).and_return(stock_locations_scope) + allow(stock_locations_scope).to receive(:not).and_return(stock_locations_scope) + allow(stock_locations_scope).to receive(:where).with(name: 'Main Warehouse').and_return(stock_locations_named_scope) + allow(stock_locations_named_scope).to receive(:exists?).and_return(false) allow(Spree::StockLocation).to receive(:new).and_return(new_location) allow(new_location).to receive(:name=) @@ -75,17 +79,21 @@ module Duplicators it 'creates a fallback default stock location when the source store has none' do products_relation = instance_double('ActiveRecord::Relation') - old_store = instance_double('Spree::Store', vendor: nil, products: products_relation) + old_store = double('Spree::Store', vendor: nil, products: products_relation) new_store = instance_double('Spree::Store', name: 'Gallery shop', default_country: :us_country) vendor_stock_locations = instance_double('ActiveRecord::Relation') vendor = instance_double('Spree::Vendor', id: 9, name: 'vendor@example.com', stock_locations: vendor_stock_locations) new_location = instance_double('Spree::StockLocation') + stock_locations_scope = instance_double('ActiveRecord::Relation') + stock_locations_named_scope = instance_double('ActiveRecord::Relation') allow(products_relation).to receive(:includes).with(variants_including_master: { stock_items: :stock_location }).and_return([]) allow(vendor_stock_locations).to receive(:order).with(default: :desc, id: :asc).and_return([]) allow(vendor_stock_locations).to receive(:find_by).with(default: true).and_return(nil) - allow(vendor_stock_locations).to receive(:where).and_return(vendor_stock_locations) - allow(vendor_stock_locations).to receive(:exists?).and_return(false) + allow(Spree::StockLocation).to receive(:where).and_return(stock_locations_scope) + allow(stock_locations_scope).to receive(:not).and_return(stock_locations_scope) + allow(stock_locations_scope).to receive(:where).with(name: 'US location').and_return(stock_locations_named_scope) + allow(stock_locations_named_scope).to receive(:exists?).and_return(false) allow(Spree::StockLocation).to receive(:new).and_return(new_location) allow(new_location).to receive(:name=) From cef8cf38aacba36ccc89ff68691488d335c8029c Mon Sep 17 00:00:00 2001 From: kipsang Date: Sat, 2 May 2026 09:20:47 +0300 Subject: [PATCH 02/10] feat: improve user role validation and enhance theme attribute assignment in sync process --- .../spree/api/v2/platform/ai/base_controller.rb | 2 +- .../spree/olitt/clone_store/ai_theme/sync.rb | 15 ++++----------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/app/controllers/spree/api/v2/platform/ai/base_controller.rb b/app/controllers/spree/api/v2/platform/ai/base_controller.rb index 8903bce..90352ec 100644 --- a/app/controllers/spree/api/v2/platform/ai/base_controller.rb +++ b/app/controllers/spree/api/v2/platform/ai/base_controller.rb @@ -51,7 +51,7 @@ def spree_current_user end def superuser_with_global_admin_role?(user) - return false unless user.present? + return false if user.blank? user.role_users.joins(:role).exists?( spree_roles: { name: Spree::Role::ADMIN_ROLE }, diff --git a/app/services/spree/olitt/clone_store/ai_theme/sync.rb b/app/services/spree/olitt/clone_store/ai_theme/sync.rb index e0c8de2..153f269 100644 --- a/app/services/spree/olitt/clone_store/ai_theme/sync.rb +++ b/app/services/spree/olitt/clone_store/ai_theme/sync.rb @@ -231,7 +231,7 @@ def theme_class def assign_theme_attributes(theme, payload) assign_if_possible(theme, :name, payload[:name]) if payload[:name].present? assign_if_possible(theme, :store, @store) if @store.present? && theme.respond_to?(:store=) - assign_if_possible(theme, :default, false) if theme.respond_to?(:default=) + assign_if_possible(theme, :default, payload.fetch(:default, false)) if theme.respond_to?(:default=) assign_if_possible(theme, :ready, false) if theme.respond_to?(:ready=) end @@ -494,11 +494,11 @@ def resolve_page_relation(theme) page_class.where(pageable: theme) end - def with_transaction(record) + def with_transaction(record, &block) if record.respond_to?(:class) && record.class.respond_to?(:transaction) - record.class.transaction { yield } + record.class.transaction(&block) else - yield + block.call end end @@ -516,13 +516,6 @@ def locate_theme(payload) theme_class.new end - def assign_theme_attributes(theme, payload) - assign_if_possible(theme, :name, payload[:name]) if payload[:name].present? - assign_if_possible(theme, :store, @store) if @store.present? && theme.respond_to?(:store=) - assign_if_possible(theme, :default, payload.fetch(:default, false)) if theme.respond_to?(:default=) - assign_if_possible(theme, :ready, false) if theme.respond_to?(:ready=) - end - def default_status(theme) return 'published' if theme.respond_to?(:ready) && theme.ready From 72c539e0f6792a1438c2f556aa45e1a5716ef91e Mon Sep 17 00:00:00 2001 From: kipsang Date: Mon, 4 May 2026 09:10:21 +0300 Subject: [PATCH 03/10] feat: enhance admin user creation process and improve attribute configuration --- .../spree/olitt/clone_store/ai_theme/sync.rb | 20 ++++++++++++ .../clone_store/clone_request_provisioner.rb | 32 +++++++++++++++---- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/app/services/spree/olitt/clone_store/ai_theme/sync.rb b/app/services/spree/olitt/clone_store/ai_theme/sync.rb index 153f269..48ae5b9 100644 --- a/app/services/spree/olitt/clone_store/ai_theme/sync.rb +++ b/app/services/spree/olitt/clone_store/ai_theme/sync.rb @@ -261,14 +261,34 @@ def resolve_page(theme, payload) end def assign_page_attributes(page, payload, theme) + assign_page_relationship(page, theme) + assign_page_identity(page, payload) + assign_page_metadata(page, payload) + assign_page_preferences(page, payload) + assign_page_type(page, payload) + end + + def assign_page_relationship(page, theme) assign_if_possible(page, :pageable, theme) if page.respond_to?(:pageable=) + end + + def assign_page_identity(page, payload) assign_if_possible(page, :name, payload[:name].presence || payload[:title].presence || default_name(payload)) assign_if_possible(page, :slug, payload[:slug].presence || parameterize_identifier(payload[:name] || payload[:title] || default_name(payload))) if page.respond_to?(:slug=) + end + + def assign_page_metadata(page, payload) assign_if_possible(page, :meta_title, payload[:meta_title]) if payload.key?(:meta_title) && page.respond_to?(:meta_title=) assign_if_possible(page, :meta_description, payload[:meta_description]) if payload.key?(:meta_description) && page.respond_to?(:meta_description=) assign_if_possible(page, :meta_keywords, payload[:meta_keywords]) if payload.key?(:meta_keywords) && page.respond_to?(:meta_keywords=) assign_if_possible(page, :visible, payload.fetch(:visible, true)) if page.respond_to?(:visible=) + end + + def assign_page_preferences(page, payload) assign_if_possible(page, :preferences, merge_preferences(page.preferences, 'ai_theme' => page_spec_payload(payload))) if page.respond_to?(:preferences=) + end + + def assign_page_type(page, payload) assign_if_possible(page, :type, resolve_page_class(payload).name) if page.respond_to?(:type=) end diff --git a/app/services/spree/olitt/clone_store/clone_request_provisioner.rb b/app/services/spree/olitt/clone_store/clone_request_provisioner.rb index 443da42..0d8d589 100644 --- a/app/services/spree/olitt/clone_store/clone_request_provisioner.rb +++ b/app/services/spree/olitt/clone_store/clone_request_provisioner.rb @@ -91,24 +91,42 @@ def find_or_create_vendor(email) end def find_or_create_admin_user(email, password, legacy_user: nil) - admin_user = existing_admin_user(email) || Spree.admin_user_class.find_or_initialize_by(email: email) + admin_user = build_admin_user(email) revive_admin_user!(admin_user) if admin_user.present? return [admin_user, false] if admin_user.persisted? + configure_admin_user(admin_user, email, password, legacy_user) + persist_admin_user(admin_user) + rescue ActiveRecord::RecordNotUnique + recover_existing_admin_user(email) + end + + def build_admin_user(email) + existing_admin_user(email) || Spree.admin_user_class.find_or_initialize_by(email: email) + end + + def configure_admin_user(admin_user, email, password, legacy_user) admin_user.login ||= email if admin_user.respond_to?(:login=) admin_user.password = password admin_user.password_confirmation = password if admin_user.respond_to?(:password_confirmation=) + copy_legacy_admin_user_attributes(admin_user, legacy_user) + end - if legacy_user.present? - admin_user.first_name ||= legacy_user.first_name if admin_user.respond_to?(:first_name=) - admin_user.last_name ||= legacy_user.last_name if admin_user.respond_to?(:last_name=) - admin_user.selected_locale ||= legacy_user.selected_locale if admin_user.respond_to?(:selected_locale=) - end + def copy_legacy_admin_user_attributes(admin_user, legacy_user) + return unless legacy_user.present? + + admin_user.first_name ||= legacy_user.first_name if admin_user.respond_to?(:first_name=) + admin_user.last_name ||= legacy_user.last_name if admin_user.respond_to?(:last_name=) + admin_user.selected_locale ||= legacy_user.selected_locale if admin_user.respond_to?(:selected_locale=) + end + def persist_admin_user(admin_user) admin_user.save! [admin_user, true] - rescue ActiveRecord::RecordNotUnique + end + + def recover_existing_admin_user(email) admin_user = existing_admin_user(email) revive_admin_user!(admin_user) if admin_user.present? return [admin_user, false] if admin_user.present? From 03ff34ca350da223eb777ae8d43b04d0366d231e Mon Sep 17 00:00:00 2001 From: kipsang Date: Mon, 4 May 2026 09:25:47 +0300 Subject: [PATCH 04/10] feat: add admin_user association to CloneRequest and enhance AdminUserDecorator for clone requests --- app/models/spree/admin_user_decorator.rb | 12 ++++++++++++ app/models/spree/olitt/clone_store/clone_request.rb | 2 +- .../spree/olitt/clone_store/ai_theme/sync.rb | 2 +- .../olitt/clone_store/clone_request_provisioner.rb | 2 +- .../spree/olitt/clone_store/ai_theme/sync_spec.rb | 5 +---- 5 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 app/models/spree/admin_user_decorator.rb diff --git a/app/models/spree/admin_user_decorator.rb b/app/models/spree/admin_user_decorator.rb new file mode 100644 index 0000000..1883bcb --- /dev/null +++ b/app/models/spree/admin_user_decorator.rb @@ -0,0 +1,12 @@ +module Spree + module AdminUserDecorator + def self.prepended(base) + base.has_many :clone_requests, + class_name: 'Spree::Olitt::CloneStore::CloneRequest', + foreign_key: :user_id, + inverse_of: :admin_user + end + end +end + +Spree.admin_user_class.prepend(Spree::AdminUserDecorator) unless Spree.admin_user_class < Spree::AdminUserDecorator \ No newline at end of file diff --git a/app/models/spree/olitt/clone_store/clone_request.rb b/app/models/spree/olitt/clone_store/clone_request.rb index ceec195..5f2c1e1 100644 --- a/app/models/spree/olitt/clone_store/clone_request.rb +++ b/app/models/spree/olitt/clone_store/clone_request.rb @@ -18,7 +18,7 @@ class CloneRequest < ::ApplicationRecord belongs_to :source_store, class_name: 'Spree::Store' belongs_to :store, class_name: 'Spree::Store', optional: true belongs_to :vendor, class_name: 'Spree::Vendor', optional: true - belongs_to :admin_user, class_name: Spree.admin_user_class.to_s, foreign_key: :user_id, optional: true + belongs_to :admin_user, class_name: Spree.admin_user_class.to_s, foreign_key: :user_id, optional: true, inverse_of: :clone_requests belongs_to :role_user, class_name: 'Spree::RoleUser', optional: true validates :store_name, :store_url, :store_code, :store_mail_from_address, :vendor_email, :vendor_password, presence: true diff --git a/app/services/spree/olitt/clone_store/ai_theme/sync.rb b/app/services/spree/olitt/clone_store/ai_theme/sync.rb index 48ae5b9..0cb4c26 100644 --- a/app/services/spree/olitt/clone_store/ai_theme/sync.rb +++ b/app/services/spree/olitt/clone_store/ai_theme/sync.rb @@ -518,7 +518,7 @@ def with_transaction(record, &block) if record.respond_to?(:class) && record.class.respond_to?(:transaction) record.class.transaction(&block) else - block.call + yield end end diff --git a/app/services/spree/olitt/clone_store/clone_request_provisioner.rb b/app/services/spree/olitt/clone_store/clone_request_provisioner.rb index 0d8d589..91c072e 100644 --- a/app/services/spree/olitt/clone_store/clone_request_provisioner.rb +++ b/app/services/spree/olitt/clone_store/clone_request_provisioner.rb @@ -113,7 +113,7 @@ def configure_admin_user(admin_user, email, password, legacy_user) end def copy_legacy_admin_user_attributes(admin_user, legacy_user) - return unless legacy_user.present? + return if legacy_user.blank? admin_user.first_name ||= legacy_user.first_name if admin_user.respond_to?(:first_name=) admin_user.last_name ||= legacy_user.last_name if admin_user.respond_to?(:last_name=) diff --git a/spec/services/spree/olitt/clone_store/ai_theme/sync_spec.rb b/spec/services/spree/olitt/clone_store/ai_theme/sync_spec.rb index 7f14451..5033ce6 100644 --- a/spec/services/spree/olitt/clone_store/ai_theme/sync_spec.rb +++ b/spec/services/spree/olitt/clone_store/ai_theme/sync_spec.rb @@ -3,6 +3,7 @@ module AiThemeSyncSpecSupport class FakeCollection attr_reader :records + delegate :size, to: :records def initialize(klass) @klass = klass @@ -36,10 +37,6 @@ def map(&block) records.map(&block) end - def size - records.size - end - def to_a records.dup end From cd484ace5adedf9b59f7ea7062bd1a11e86dec4a Mon Sep 17 00:00:00 2001 From: kipsang Date: Mon, 4 May 2026 09:30:55 +0300 Subject: [PATCH 05/10] feat: enhance clone request association and improve admin user revival process --- app/models/spree/admin_user_decorator.rb | 3 ++- .../spree/olitt/clone_store/clone_request_provisioner.rb | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/models/spree/admin_user_decorator.rb b/app/models/spree/admin_user_decorator.rb index 1883bcb..5edf619 100644 --- a/app/models/spree/admin_user_decorator.rb +++ b/app/models/spree/admin_user_decorator.rb @@ -4,7 +4,8 @@ def self.prepended(base) base.has_many :clone_requests, class_name: 'Spree::Olitt::CloneStore::CloneRequest', foreign_key: :user_id, - inverse_of: :admin_user + inverse_of: :admin_user, + dependent: :nullify end end end diff --git a/app/services/spree/olitt/clone_store/clone_request_provisioner.rb b/app/services/spree/olitt/clone_store/clone_request_provisioner.rb index 91c072e..9a1d5e9 100644 --- a/app/services/spree/olitt/clone_store/clone_request_provisioner.rb +++ b/app/services/spree/olitt/clone_store/clone_request_provisioner.rb @@ -201,7 +201,7 @@ def revive_vendor!(vendor) def revive_admin_user!(admin_user) return if admin_user.blank? || !admin_user.respond_to?(:deleted_at) || admin_user.deleted_at.blank? - admin_user.update_columns(deleted_at: nil, updated_at: Time.current) + admin_user.update!(deleted_at: nil, updated_at: Time.current) end end end From 614212fc6bc3d074d17731c43e1cafc077b4fd5c Mon Sep 17 00:00:00 2001 From: kipsang Date: Mon, 4 May 2026 10:03:03 +0300 Subject: [PATCH 06/10] feat: remove AI theme store decorator and sync service, update clone request presenter to exclude vendor password and auto-login details --- app/models/spree/ai_theme_store_decorator.rb | 14 - .../spree/olitt/clone_store/ai_theme/sync.rb | 548 ------------------ .../clone_store/clone_request_presenter.rb | 12 - config/routes.rb | 13 - .../platform/clone_stores_controller_spec.rb | 6 +- .../olitt/clone_store/ai_theme/sync_spec.rb | 250 -------- .../clone_request_presenter_spec.rb | 4 - .../clone_request_provisioner_spec.rb | 2 +- 8 files changed, 2 insertions(+), 847 deletions(-) delete mode 100644 app/models/spree/ai_theme_store_decorator.rb delete mode 100644 app/services/spree/olitt/clone_store/ai_theme/sync.rb delete mode 100644 spec/services/spree/olitt/clone_store/ai_theme/sync_spec.rb diff --git a/app/models/spree/ai_theme_store_decorator.rb b/app/models/spree/ai_theme_store_decorator.rb deleted file mode 100644 index a31a5f3..0000000 --- a/app/models/spree/ai_theme_store_decorator.rb +++ /dev/null @@ -1,14 +0,0 @@ -module Spree - module AiThemeStoreDecorator - def self.prepended(base) - base.preference :ai_theme_payload, :text, default: nil - base.preference :ai_theme_status, :string, default: 'draft' - base.preference :ai_theme_version, :integer, default: 1 - base.preference :ai_theme_preview_token, :string, default: nil - base.preference :ai_theme_preview_expires_at, :string, default: nil - base.preference :ai_theme_published_at, :string, default: nil - end - end -end - -Spree::Store.prepend(Spree::AiThemeStoreDecorator) \ No newline at end of file diff --git a/app/services/spree/olitt/clone_store/ai_theme/sync.rb b/app/services/spree/olitt/clone_store/ai_theme/sync.rb deleted file mode 100644 index 0cb4c26..0000000 --- a/app/services/spree/olitt/clone_store/ai_theme/sync.rb +++ /dev/null @@ -1,548 +0,0 @@ -require 'digest' -require 'json' -require 'securerandom' - -module Spree - module Olitt - module CloneStore - module AiTheme - class Sync - PAGE_KIND_CLASS_MAP = { - 'homepage' => 'Spree::Pages::Homepage', - 'shop_all' => 'Spree::Pages::ShopAll' - }.freeze - - attr_reader :errors - - def initialize(store: nil, theme: nil) - @store = store - @theme = theme - @errors = [] - end - - def upsert_theme(params) - payload = normalize_payload(params) - theme = resolve_theme(payload) - - with_transaction(theme) do - assign_theme_attributes(theme, payload) - theme.save! - sync_pages(theme, payload[:spec]) - persist_theme_state!(theme, payload) - end - - theme.reload if theme.respond_to?(:reload) - theme - rescue StandardError => e - capture_error(e) - nil - end - - def upsert_page(theme, params) - payload = normalize_payload(params) - - with_transaction(theme) do - page = resolve_page(theme, payload) - assign_page_attributes(page, payload, theme) - page.save! - sync_sections(page, payload[:sections]) - page - end - rescue StandardError => e - capture_error(e) - nil - end - - def upsert_section(page, params) - payload = normalize_payload(params) - - with_transaction(page) do - section = resolve_section(page, payload) - assign_section_attributes(section, payload, page) - section.save! - sync_blocks(section, payload[:blocks]) - section - end - rescue StandardError => e - capture_error(e) - nil - end - - def snapshot_version(theme, params) - payload = normalize_payload(params) - with_transaction(theme) do - version = next_version(theme) - theme_state = theme_state(theme) - revisions = Array(theme_state['versions']) - snapshot = { - 'revision' => version, - 'checksum' => payload[:checksum].presence || checksum_for(payload[:spec] || theme_state['spec'] || {}), - 'spec' => payload[:spec] || theme_state['spec'] || {}, - 'created_at' => Time.current.iso8601 - } - revisions << snapshot - persist_theme_state!(theme, { version: version, versions: revisions, spec: snapshot['spec'] }) - snapshot - end - rescue StandardError => e - capture_error(e) - nil - end - - def preview_theme(theme) - with_transaction(theme) do - token = SecureRandom.hex(16) - persist_theme_state!(theme, { - status: 'preview', - preview_token: token, - preview_expires_at: 1.hour.from_now.iso8601, - ready: false - }) - token - end - rescue StandardError => e - capture_error(e) - nil - end - - def publish_theme(theme) - with_transaction(theme) do - persist_theme_state!(theme, { - status: 'published', - published_at: Time.current.iso8601, - ready: true - }) - theme.update!(ready: true) if theme.respond_to?(:update!) && theme.respond_to?(:ready=) - theme - end - rescue StandardError => e - capture_error(e) - nil - end - - def theme_payload(theme) - state = theme_state(theme) - { - data: { - id: theme.id.to_s, - type: 'ai_theme', - attributes: { - name: theme.try(:name), - store_id: theme.try(:store_id), - status: state['status'].presence || default_status(theme), - version: state['version'].presence || 1, - prompt: state['prompt'], - spec: state['spec'] || {}, - preview_token: state['preview_token'], - preview_expires_at: state['preview_expires_at'], - published_at: state['published_at'], - ready: state.key?('ready') ? state['ready'] : theme.try(:ready), - pages: serialize_pages(theme) - } - }, - meta: { - theme_id: theme.id, - store_id: theme.try(:store_id), - status: state['status'].presence || default_status(theme), - version: state['version'].presence || 1 - } - } - end - - def page_payload(page) - state = preferences_hash(page) - { - data: { - id: page.id.to_s, - type: 'ai_page', - attributes: { - type: page.try(:type), - name: page.try(:name), - slug: page.try(:slug), - meta_title: page.try(:meta_title), - meta_description: page.try(:meta_description), - meta_keywords: page.try(:meta_keywords), - preferences: state['ai_theme'] || state, - sections: serialize_sections(page) - } - }, - meta: { - page_id: page.id, - theme_id: page.try(:pageable_id) - } - } - end - - def section_payload(section) - state = preferences_hash(section) - { - data: { - id: section.id.to_s, - type: 'ai_section', - attributes: { - type: section.try(:type), - name: section.try(:name), - position: section.try(:position), - preferences: state['ai_theme'] || state, - blocks: serialize_blocks(section) - } - }, - meta: { - section_id: section.id, - page_id: section.try(:pageable_id) - } - } - end - - private - - def normalize_payload(params) - raw = if params.respond_to?(:to_unsafe_h) - params.to_unsafe_h - else - params.to_h - end - - raw.deep_symbolize_keys - end - - def resolve_theme(payload) - return @theme if @theme.present? - - theme_id = payload[:theme_id] || payload[:id] - return theme_by_id(theme_id) if theme_id.present? - - return @store.themes.find_or_initialize_by(name: payload[:name]) if @store.respond_to?(:themes) && payload[:name].present? - - theme_class.new - end - - def theme_by_id(theme_id) - return @theme if @theme.present? - return theme_class.find_by(id: theme_id) if theme_class.respond_to?(:find_by) - - nil - end - - def theme_class - Spree::Theme - end - - def assign_theme_attributes(theme, payload) - assign_if_possible(theme, :name, payload[:name]) if payload[:name].present? - assign_if_possible(theme, :store, @store) if @store.present? && theme.respond_to?(:store=) - assign_if_possible(theme, :default, payload.fetch(:default, false)) if theme.respond_to?(:default=) - assign_if_possible(theme, :ready, false) if theme.respond_to?(:ready=) - end - - def sync_pages(theme, spec) - Array(spec&.fetch(:pages, [])).each do |page_spec| - upsert_page(theme, page_spec) - end - end - - def resolve_page(theme, payload) - page_class = resolve_page_class(payload) - scope = if theme.respond_to?(:pages) - theme.pages - else - page_class - end - - criteria = {} - criteria[:slug] = payload[:slug].presence || parameterize_identifier(payload[:name] || payload[:title] || page_class.name) - criteria[:type] = page_class.name if page_class.respond_to?(:name) - - if scope.respond_to?(:find_or_initialize_by) - scope.find_or_initialize_by(criteria) - else - page_class.new - end - end - - def assign_page_attributes(page, payload, theme) - assign_page_relationship(page, theme) - assign_page_identity(page, payload) - assign_page_metadata(page, payload) - assign_page_preferences(page, payload) - assign_page_type(page, payload) - end - - def assign_page_relationship(page, theme) - assign_if_possible(page, :pageable, theme) if page.respond_to?(:pageable=) - end - - def assign_page_identity(page, payload) - assign_if_possible(page, :name, payload[:name].presence || payload[:title].presence || default_name(payload)) - assign_if_possible(page, :slug, payload[:slug].presence || parameterize_identifier(payload[:name] || payload[:title] || default_name(payload))) if page.respond_to?(:slug=) - end - - def assign_page_metadata(page, payload) - assign_if_possible(page, :meta_title, payload[:meta_title]) if payload.key?(:meta_title) && page.respond_to?(:meta_title=) - assign_if_possible(page, :meta_description, payload[:meta_description]) if payload.key?(:meta_description) && page.respond_to?(:meta_description=) - assign_if_possible(page, :meta_keywords, payload[:meta_keywords]) if payload.key?(:meta_keywords) && page.respond_to?(:meta_keywords=) - assign_if_possible(page, :visible, payload.fetch(:visible, true)) if page.respond_to?(:visible=) - end - - def assign_page_preferences(page, payload) - assign_if_possible(page, :preferences, merge_preferences(page.preferences, 'ai_theme' => page_spec_payload(payload))) if page.respond_to?(:preferences=) - end - - def assign_page_type(page, payload) - assign_if_possible(page, :type, resolve_page_class(payload).name) if page.respond_to?(:type=) - end - - def sync_sections(page, sections_spec) - Array(sections_spec).each do |section_spec| - upsert_section(page, section_spec) - end - end - - def resolve_section(page, payload) - section_class = resolve_section_class(payload) - scope = if page.respond_to?(:sections) - page.sections - else - section_class - end - - criteria = {} - criteria[:name] = payload[:name].presence || default_name(payload) - criteria[:type] = section_class.name if section_class.respond_to?(:name) - - if scope.respond_to?(:find_or_initialize_by) - scope.find_or_initialize_by(criteria) - else - section_class.new - end - end - - def assign_section_attributes(section, payload, page) - assign_if_possible(section, :pageable, page) if section.respond_to?(:pageable=) - assign_if_possible(section, :name, payload[:name].presence || default_name(payload)) - assign_if_possible(section, :position, payload[:position].presence || 1) - assign_if_possible(section, :preferences, merge_preferences(section.preferences, 'ai_theme' => section_spec_payload(payload))) if section.respond_to?(:preferences=) - assign_if_possible(section, :type, resolve_section_class(payload).name) if section.respond_to?(:type=) - assign_if_possible(section, :content, payload[:content].to_json) if payload.key?(:content) && section.respond_to?(:content=) - assign_if_possible(section, :settings, payload[:settings].to_json) if payload.key?(:settings) && section.respond_to?(:settings=) - end - - def sync_blocks(section, blocks_spec) - return if blocks_spec.blank? - - Array(blocks_spec).each do |block_spec| - upsert_block(section, block_spec) - end - end - - def upsert_block(section, payload) - block_class = resolve_block_class(payload) - scope = if section.respond_to?(:blocks) - section.blocks - else - block_class - end - - criteria = {} - criteria[:name] = payload[:name].presence || default_name(payload) - criteria[:type] = block_class.name if block_class.respond_to?(:name) - - block = if scope.respond_to?(:find_or_initialize_by) - scope.find_or_initialize_by(criteria) - else - block_class.new - end - - assign_if_possible(block, :section, section) if block.respond_to?(:section=) - assign_if_possible(block, :name, payload[:name].presence || default_name(payload)) - assign_if_possible(block, :position, payload[:position].presence || 1) - assign_if_possible(block, :preferences, merge_preferences(block.preferences, 'ai_theme' => block_spec_payload(payload))) if block.respond_to?(:preferences=) - assign_if_possible(block, :type, block_class.name) if block.respond_to?(:type=) - assign_if_possible(block, :content, payload[:content].to_json) if payload.key?(:content) && block.respond_to?(:content=) - assign_if_possible(block, :settings, payload[:settings].to_json) if payload.key?(:settings) && block.respond_to?(:settings=) - block.save! - block - end - - def resolve_page_class(payload) - class_name = class_name_for(payload[:class_name] || payload[:type], PAGE_KIND_CLASS_MAP, Spree::Page) - class_name.safe_constantize || Spree::Page - end - - def resolve_section_class(payload) - class_name = class_name_for(payload[:class_name] || payload[:type], {}, Spree::PageSection) - class_name.safe_constantize || Spree::PageSection - end - - def resolve_block_class(payload) - class_name = class_name_for(payload[:class_name] || payload[:type], {}, Spree::PageBlock) - class_name.safe_constantize || Spree::PageBlock - end - - def class_name_for(raw_value, mapping, default_class) - value = raw_value.to_s.strip - return default_class.name if value.blank? - return value if value.include?('::') - - mapping.fetch(value, default_class.name) - end - - def default_name(payload) - payload[:name].presence || payload[:title].presence || 'Untitled' - end - - def parameterize_identifier(value) - value.to_s.parameterize.presence || SecureRandom.hex(6) - end - - def page_spec_payload(payload) - payload.slice(:name, :title, :slug, :class_name, :type, :meta_title, :meta_description, :meta_keywords, :visible) - end - - def section_spec_payload(payload) - payload.slice(:name, :class_name, :type, :position, :content, :settings) - end - - def block_spec_payload(payload) - payload.slice(:name, :class_name, :type, :position, :content, :settings) - end - - def sync_theme_metadata(theme, payload) - state = theme_state(theme) - persist_theme_state!(theme, state.merge( - 'status' => payload[:status], - 'prompt' => payload[:prompt], - 'spec' => payload[:spec] || state['spec'] || {}, - 'version' => payload[:version] || state['version'] || 1 - ).compact) - end - - def persist_theme_state!(theme, payload) - state = theme_state(theme).merge(payload.deep_stringify_keys) - assign_if_possible(theme, :preferences, merge_preferences(theme.preferences, 'ai_theme' => state)) if theme.respond_to?(:preferences=) - theme.save! if theme.respond_to?(:save!) - state - end - - def theme_state(theme) - state = preferences_hash(theme)['ai_theme'] - state = state.to_h if state.respond_to?(:to_h) - state = {} unless state.is_a?(Hash) - state.deep_stringify_keys - end - - def preferences_hash(record) - raw = if record.respond_to?(:preferences) - record.preferences - else - {} - end - - raw = raw.to_h if raw.respond_to?(:to_h) && !raw.is_a?(Hash) - raw = JSON.parse(raw) if raw.is_a?(String) && raw.present? - raw = {} unless raw.is_a?(Hash) - raw.deep_stringify_keys - rescue JSON::ParserError - {} - end - - def merge_preferences(existing_preferences, payload) - preferences = existing_preferences - preferences = preferences.to_h if preferences.respond_to?(:to_h) && !preferences.is_a?(Hash) - preferences = JSON.parse(preferences) if preferences.is_a?(String) && preferences.present? - preferences = {} unless preferences.is_a?(Hash) - preferences.deep_stringify_keys.merge(payload.deep_stringify_keys) - rescue JSON::ParserError - payload.deep_stringify_keys - end - - def serialize_pages(theme) - return [] unless theme.respond_to?(:pages) - - Array(theme.pages).map { |page| page_payload(page)[:data][:attributes].merge(id: page.id) } - end - - def serialize_sections(page) - return [] unless page.respond_to?(:sections) - - Array(page.sections).map { |section| section_payload(section)[:data][:attributes].merge(id: section.id) } - end - - def serialize_blocks(section) - return [] unless section.respond_to?(:blocks) - - Array(section.blocks).map do |block| - block_state = preferences_hash(block) - { - id: block.id, - type: block.try(:type), - name: block.try(:name), - position: block.try(:position), - preferences: block_state['ai_theme'] || block_state - } - end - end - - def next_version(theme) - state = theme_state(theme) - Array(state['versions']).size + 1 - end - - def checksum_for(spec) - Digest::SHA256.hexdigest(spec.to_json) - end - - def assign_if_possible(record, attribute, value) - writer = "#{attribute}=" - record.public_send(writer, value) if record.respond_to?(writer) - end - - def resolve_theme_store(theme) - return @store if @store.present? - return theme.store if theme.respond_to?(:store) - - nil - end - - def capture_error(exception) - @errors = Array(exception.respond_to?(:record) ? exception.record.errors.full_messages.presence || exception.message : exception.message) - end - - def resolve_page_relation(theme) - return theme.pages if theme.respond_to?(:pages) - page_class = Spree::Page - page_class.where(pageable: theme) - end - - def with_transaction(record, &block) - if record.respond_to?(:class) && record.class.respond_to?(:transaction) - record.class.transaction(&block) - else - yield - end - end - - def locate_theme(payload) - return @theme if @theme.present? - - theme_id = payload[:theme_id] || payload[:id] - return theme_class.find_by(id: theme_id) if theme_id.present? && theme_class.respond_to?(:find_by) - - if @store.respond_to?(:themes) - return @store.themes.find_or_initialize_by(name: payload[:name]) if payload[:name].present? - return @store.themes.find_by(default: true) || @store.themes.build - end - - theme_class.new - end - - def default_status(theme) - return 'published' if theme.respond_to?(:ready) && theme.ready - - 'draft' - end - end - end - end - end -end \ No newline at end of file diff --git a/app/services/spree/olitt/clone_store/clone_request_presenter.rb b/app/services/spree/olitt/clone_store/clone_request_presenter.rb index 1c13743..3819d9a 100644 --- a/app/services/spree/olitt/clone_store/clone_request_presenter.rb +++ b/app/services/spree/olitt/clone_store/clone_request_presenter.rb @@ -48,25 +48,13 @@ def vendor_metadata vendor_slug: @clone_request.vendor&.slug, admin_user_id: @clone_request.admin_user&.id, email: @clone_request.vendor_email, - password: @clone_request.vendor_password, - auto_login_path: auto_login_path, - auto_login_url: auto_login_url, next_path: admin_dashboard_path } end - def auto_login_path - '/admin/auto_login' - end - def admin_dashboard_path '/admin' end - - def auto_login_url - query = Rack::Utils.build_query(email: @clone_request.vendor_email, password: @clone_request.vendor_password, next: admin_dashboard_path) - "#{auto_login_path}?#{query}" - end end end end diff --git a/config/routes.rb b/config/routes.rb index a000cf4..82d2701 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,19 +4,6 @@ namespace :platform do post '/clone-store', to: 'clone_stores#create' get '/clone-store/:clone_request_id', to: 'clone_stores#show' - - namespace :ai do - resources :themes, only: %i[create show], param: :theme_id do - post :preview, on: :member - post :publish, on: :member - resources :versions, only: :create, controller: 'theme_versions' - resources :pages, only: :create, controller: 'theme_pages' - end - - resources :pages, only: [], param: :page_id do - resources :sections, only: :create, controller: 'page_sections' - end - end end end end diff --git a/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb b/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb index fe982d7..643dec8 100644 --- a/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb +++ b/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb @@ -41,7 +41,7 @@ ActiveJob::Base.queue_adapter = :test end - it 'clones a store with products and returns vendor autologin details' do + it 'clones a store with products and returns vendor metadata' do post :create, params: clone_params, format: :json expect(response).to have_http_status(:accepted) @@ -62,11 +62,7 @@ vendor_meta = payload.fetch('meta').fetch('vendor') expect(vendor_meta).to include( 'email' => 'clone.vendor@example.com', - 'password' => 'Password123!', - 'auto_login_path' => '/admin/auto_login', 'next_path' => '/admin' ) - expect(vendor_meta.fetch('auto_login_url')).to include('email=clone.vendor%40example.com') - expect(vendor_meta.fetch('auto_login_url')).to include('password=Password123%21') end end \ No newline at end of file diff --git a/spec/services/spree/olitt/clone_store/ai_theme/sync_spec.rb b/spec/services/spree/olitt/clone_store/ai_theme/sync_spec.rb deleted file mode 100644 index 5033ce6..0000000 --- a/spec/services/spree/olitt/clone_store/ai_theme/sync_spec.rb +++ /dev/null @@ -1,250 +0,0 @@ -require 'spec_helper' - -module AiThemeSyncSpecSupport - class FakeCollection - attr_reader :records - delegate :size, to: :records - - def initialize(klass) - @klass = klass - @records = [] - end - - def find_or_initialize_by(criteria) - records.find { |record| criteria.all? { |key, value| record.public_send(key) == value } } || begin - record = @klass.new - criteria.each { |key, value| record.public_send("#{key}=", value) } - records << record - record - end - end - - def find_by(criteria) - records.find { |record| criteria.all? { |key, value| record.public_send(key) == value } } - end - - def build - record = @klass.new - records << record - record - end - - def each(&block) - records.each(&block) - end - - def map(&block) - records.map(&block) - end - - def to_a - records.dup - end - end - - class FakeBlock - attr_accessor :id, :name, :position, :type, :preferences, :content, :settings, :section - - def initialize - @preferences = {} - end - - def save! - self.id ||= SecureRandom.random_number(100_000) - true - end - end - - class FakeSection - attr_accessor :id, :name, :position, :type, :preferences, :content, :settings, :pageable - - def initialize - @preferences = {} - @blocks = FakeCollection.new(FakeBlock) - end - - def blocks - @blocks - end - - def save! - self.id ||= SecureRandom.random_number(100_000) - true - end - end - - class FakePage - attr_accessor :id, :name, :slug, :type, :meta_title, :meta_description, :meta_keywords, :visible, :preferences, :pageable - - def initialize - @preferences = {} - @sections = FakeCollection.new(FakeSection) - end - - def sections - @sections - end - - def save! - self.id ||= SecureRandom.random_number(100_000) - true - end - end - - class FakeTheme - attr_accessor :id, :name, :store, :default, :ready, :preferences - - def self.transaction - yield - end - - def initialize - @preferences = {} - @pages = FakeCollection.new(FakePage) - @ready = true - @default = false - end - - def pages - @pages - end - - def save! - self.id ||= SecureRandom.random_number(100_000) - true - end - - def reload - self - end - - def update!(attributes) - attributes.each { |key, value| public_send("#{key}=", value) } - true - end - end - - class FakeThemesRelation - attr_reader :records - - def initialize - @records = [] - end - - def find_or_initialize_by(criteria) - records.find { |record| criteria.all? { |key, value| record.public_send(key) == value } } || begin - record = FakeTheme.new - criteria.each { |key, value| record.public_send("#{key}=", value) } - records << record - record - end - end - - def find_by(criteria) - records.find { |record| criteria.all? { |key, value| record.public_send(key) == value } } - end - - def build - record = FakeTheme.new - records << record - record - end - end - - class FakeStore - attr_reader :themes - - def initialize - @themes = FakeThemesRelation.new - end - end -end - -describe Spree::Olitt::CloneStore::AiTheme::Sync do - include AiThemeSyncSpecSupport - - let(:store) { FakeStore.new } - let(:service) { described_class.new(store: store) } - - let(:theme_params) do - { - name: 'Modern Fashion', - prompt: 'Build a premium store', - spec: { - pages: [ - { - type: 'homepage', - name: 'Homepage', - slug: '/', - class_name: 'AiThemeSyncSpecSupport::FakePage', - sections: [ - { - type: 'hero', - name: 'Hero', - position: 1, - class_name: 'AiThemeSyncSpecSupport::FakeSection', - content: { headline: 'Hello' }, - blocks: [ - { - type: 'text', - name: 'Headline', - position: 1, - class_name: 'AiThemeSyncSpecSupport::FakeBlock', - content: { body: 'Welcome' } - } - ] - } - ] - } - ] - } - } - end - - it 'upserts a theme with nested pages, sections, and blocks' do - theme = service.upsert_theme(theme_params) - - expect(theme).to be_a(FakeTheme) - expect(theme.name).to eq('Modern Fashion') - expect(theme.preferences.dig('ai_theme', 'status')).to eq('draft') - expect(theme.preferences.dig('ai_theme', 'spec', 'pages').first['name']).to eq('Homepage') - expect(theme.pages.size).to eq(1) - - page = theme.pages.records.first - expect(page.name).to eq('Homepage') - expect(page.sections.size).to eq(1) - expect(page.sections.records.first.blocks.size).to eq(1) - end - - it 'serializes the theme payload' do - theme = service.upsert_theme(theme_params) - - payload = service.theme_payload(theme) - - expect(payload[:data][:attributes][:name]).to eq('Modern Fashion') - expect(payload[:data][:attributes][:pages].first[:name]).to eq('Homepage') - expect(payload[:data][:attributes][:pages].first[:sections].first[:name]).to eq('Hero') - end - - it 'creates preview and publish state' do - theme = service.upsert_theme(theme_params) - - preview_token = service.preview_theme(theme) - expect(preview_token).to be_present - expect(theme.preferences.dig('ai_theme', 'status')).to eq('preview') - - published = service.publish_theme(theme) - expect(published.ready).to be(true) - expect(theme.preferences.dig('ai_theme', 'status')).to eq('published') - end - - it 'snapshots versions' do - theme = service.upsert_theme(theme_params) - - version = service.snapshot_version(theme, spec: theme_params[:spec], checksum: 'sha256:abc123') - - expect(version['revision']).to eq(1) - expect(version['checksum']).to eq('sha256:abc123') - expect(theme.preferences.dig('ai_theme', 'versions').size).to eq(1) - end -end \ No newline at end of file diff --git a/spec/services/spree/olitt/clone_store/clone_request_presenter_spec.rb b/spec/services/spree/olitt/clone_store/clone_request_presenter_spec.rb index 0a6890d..9b4be4c 100644 --- a/spec/services/spree/olitt/clone_store/clone_request_presenter_spec.rb +++ b/spec/services/spree/olitt/clone_store/clone_request_presenter_spec.rb @@ -17,7 +17,6 @@ error_message: nil, vendor_id: 11, vendor_email: 'vendor@example.com', - vendor_password: 'secret123', vendor: instance_double(Spree::Vendor, slug: 'vendor-slug'), admin_user: instance_double(Spree.admin_user_class, id: 77), store: nil, @@ -41,9 +40,6 @@ vendor_slug: 'vendor-slug', admin_user_id: 77, email: 'vendor@example.com', - password: 'secret123', - auto_login_path: '/admin/auto_login', - auto_login_url: '/admin/auto_login?email=vendor%40example.com&password=secret123&next=%2Fadmin', next_path: '/admin' } }) diff --git a/spec/services/spree/olitt/clone_store/clone_request_provisioner_spec.rb b/spec/services/spree/olitt/clone_store/clone_request_provisioner_spec.rb index 8f016c2..263b6c1 100644 --- a/spec/services/spree/olitt/clone_store/clone_request_provisioner_spec.rb +++ b/spec/services/spree/olitt/clone_store/clone_request_provisioner_spec.rb @@ -2,7 +2,7 @@ describe Spree::Olitt::CloneStore::CloneRequestProvisioner do describe '#call' do - it 'provisions an admin user and links it to the vendor for autologin' do + it 'provisions an admin user and links it to the vendor' do source_store = create(:store, default: false, url: 'source.example.com', code: 'source-store') clone_request = Spree::Olitt::CloneStore::CloneRequest.create!( source_store: source_store, From 4f450ea85a74b230593924adf087a64bce17910e Mon Sep 17 00:00:00 2001 From: kipsang Date: Mon, 4 May 2026 10:06:48 +0300 Subject: [PATCH 07/10] feat: remove AI theme controllers and associated logic for cleaner architecture --- .../api/v2/platform/ai/base_controller.rb | 117 ------------------ .../platform/ai/page_sections_controller.rb | 57 --------- .../v2/platform/ai/theme_pages_controller.rb | 64 ---------- .../platform/ai/theme_versions_controller.rb | 43 ------- .../api/v2/platform/ai/themes_controller.rb | 100 --------------- 5 files changed, 381 deletions(-) delete mode 100644 app/controllers/spree/api/v2/platform/ai/base_controller.rb delete mode 100644 app/controllers/spree/api/v2/platform/ai/page_sections_controller.rb delete mode 100644 app/controllers/spree/api/v2/platform/ai/theme_pages_controller.rb delete mode 100644 app/controllers/spree/api/v2/platform/ai/theme_versions_controller.rb delete mode 100644 app/controllers/spree/api/v2/platform/ai/themes_controller.rb diff --git a/app/controllers/spree/api/v2/platform/ai/base_controller.rb b/app/controllers/spree/api/v2/platform/ai/base_controller.rb deleted file mode 100644 index 90352ec..0000000 --- a/app/controllers/spree/api/v2/platform/ai/base_controller.rb +++ /dev/null @@ -1,117 +0,0 @@ -module Spree - module Api - module V2 - module Platform - module Ai - class BaseController < Spree::BaseController - skip_forgery_protection - - rescue_from Doorkeeper::Errors::DoorkeeperError, with: :render_unauthorized - rescue_from ActiveRecord::RecordNotUnique, with: :render_bad_request_exception - rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid - - before_action :force_json_request_format - before_action :validate_token_client - before_action :authorize_ai_theme_request! - - private - - def force_json_request_format - request.format = :json - end - - def validate_token_client - return if doorkeeper_token.nil? - - raise Doorkeeper::Errors::DoorkeeperError if doorkeeper_token.application.nil? - end - - def render_unauthorized(_exception) - render_api_error('Unauthorized', :unauthorized) - end - - def render_api_error(message, status) - render json: { error: message }, status: status - end - - def render_bad_request_exception(exception) - render json: { errors: [exception.cause&.message || exception.message] }, status: :bad_request - end - - def render_record_invalid(exception) - render json: { errors: exception.record.errors.full_messages.presence || [exception.message] }, status: :bad_request - end - - def spree_current_user - return nil unless doorkeeper_token - return nil if doorkeeper_token.resource_owner_id.nil? - return @spree_current_user if defined?(@spree_current_user) - - @spree_current_user ||= doorkeeper_token.resource_owner - end - - def superuser_with_global_admin_role?(user) - return false if user.blank? - - user.role_users.joins(:role).exists?( - spree_roles: { name: Spree::Role::ADMIN_ROLE }, - resource_type: nil, - resource_id: nil - ) - end - - def render_forbidden - render_api_error(I18n.t('spree.forbidden'), :forbidden) - end - - def normalize_errors(errors) - Array(errors).flatten.compact.flat_map do |error| - next error.full_messages if error.respond_to?(:full_messages) - - error.to_s - end - end - - def authorize_ai_theme_request! - scopes = %i[read admin] - scopes = %i[write admin] unless %w[show].include?(action_name) - doorkeeper_authorize!(*scopes) - - return if spree_current_user.nil? - return if superuser_with_global_admin_role?(spree_current_user) - - store = store_for_authorization - return if store.blank? - - ability = Spree::VendorAbility.new(spree_current_user) rescue nil - return if ability && (ability.can?(:manage, store) || ability.can?(:admin, store)) - - render_forbidden - end - - def store_for_authorization - store_id = params[:store_id] || params.dig(:theme, :store_id) - return Spree::Store.find_by(id: store_id) if store_id.present? - - theme_id = params[:theme_theme_id] || params[:theme_id] - return theme_store(theme_id) if theme_id.present? - - page_id = params[:page_page_id] || params[:page_id] - return if page_id.blank? - - page = Spree::Page.find_by(id: page_id) - return if page.blank? - - theme_store(page.try(:pageable_id)) || page.try(:pageable) - end - - def theme_store(theme_or_theme_id) - theme = theme_or_theme_id.is_a?(Spree::Theme) ? theme_or_theme_id : Spree::Theme.find_by(id: theme_or_theme_id) - theme&.store - end - end - end - end - end - end -end \ No newline at end of file diff --git a/app/controllers/spree/api/v2/platform/ai/page_sections_controller.rb b/app/controllers/spree/api/v2/platform/ai/page_sections_controller.rb deleted file mode 100644 index feecba5..0000000 --- a/app/controllers/spree/api/v2/platform/ai/page_sections_controller.rb +++ /dev/null @@ -1,57 +0,0 @@ -module Spree - module Api - module V2 - module Platform - module Ai - class PageSectionsController < BaseController - def create - page_id = params[:page_page_id] || params[:page_id] - page = Spree::Page.find_by(id: page_id) - return render_not_found('Page not found') unless page - - section = sync_service.upsert_section(page, section_params) - return render_errors(sync_service.errors) if section.nil? - - render json: sync_service.section_payload(section), status: :created - end - - private - - def sync_service - @sync_service ||= Spree::Olitt::CloneStore::AiTheme::Sync.new - end - - def section_params - params.fetch(:section, params).permit( - :id, - :type, - :kind, - :class_name, - :name, - :position, - :prompt, - :status, - :version, - content: {}, - settings: {}, - preferences: {}, - blocks: [ - :id, :type, :kind, :class_name, :name, :position, - { content: {}, settings: {}, preferences: {} } - ] - ).to_h.symbolize_keys - end - - def render_errors(errors, status: :unprocessable_entity) - render json: { errors: normalize_errors(errors) }, status: status - end - - def render_not_found(message = 'Page not found') - render json: { errors: [message] }, status: :not_found - end - end - end - end - end - end -end \ No newline at end of file diff --git a/app/controllers/spree/api/v2/platform/ai/theme_pages_controller.rb b/app/controllers/spree/api/v2/platform/ai/theme_pages_controller.rb deleted file mode 100644 index dbba47c..0000000 --- a/app/controllers/spree/api/v2/platform/ai/theme_pages_controller.rb +++ /dev/null @@ -1,64 +0,0 @@ -module Spree - module Api - module V2 - module Platform - module Ai - class ThemePagesController < BaseController - def create - theme_id = params[:theme_theme_id] || params[:theme_id] - theme = Spree::Theme.find_by(id: theme_id) - return render_not_found('Theme not found') unless theme - - page = sync_service.upsert_page(theme, page_params) - return render_errors(sync_service.errors) if page.nil? - - render json: sync_service.page_payload(page), status: :created - end - - private - - def sync_service - @sync_service ||= Spree::Olitt::CloneStore::AiTheme::Sync.new - end - - def page_params - params.fetch(:page, params).permit( - :id, - :theme_id, - :type, - :kind, - :class_name, - :name, - :title, - :slug, - :meta_title, - :meta_description, - :meta_keywords, - :visible, - :prompt, - :status, - :version, - spec: {}, - sections: [ - :id, :type, :kind, :class_name, :name, :position, - { content: {}, settings: {}, preferences: {}, blocks: [ - :id, :type, :kind, :class_name, :name, :position, - { content: {}, settings: {}, preferences: {} } - ] } - ] - ).to_h.symbolize_keys - end - - def render_errors(errors, status: :unprocessable_entity) - render json: { errors: normalize_errors(errors) }, status: status - end - - def render_not_found(message = 'Theme not found') - render json: { errors: [message] }, status: :not_found - end - end - end - end - end - end -end \ No newline at end of file diff --git a/app/controllers/spree/api/v2/platform/ai/theme_versions_controller.rb b/app/controllers/spree/api/v2/platform/ai/theme_versions_controller.rb deleted file mode 100644 index 0b625a8..0000000 --- a/app/controllers/spree/api/v2/platform/ai/theme_versions_controller.rb +++ /dev/null @@ -1,43 +0,0 @@ -module Spree - module Api - module V2 - module Platform - module Ai - class ThemeVersionsController < BaseController - def create - theme_id = params[:theme_theme_id] || params[:theme_id] - theme = Spree::Theme.find_by(id: theme_id) - return render_not_found('Theme not found') unless theme - - version = sync_service.snapshot_version(theme, version_params) - return render_errors(sync_service.errors) if version.nil? - - render json: { data: { id: version['revision'].to_s, type: 'ai_theme_version', attributes: version } }, status: :created - end - - private - - def sync_service - @sync_service ||= Spree::Olitt::CloneStore::AiTheme::Sync.new - end - - def version_params - params.fetch(:version, params).permit( - :checksum, - spec: {} - ).to_h.symbolize_keys - end - - def render_errors(errors, status: :unprocessable_entity) - render json: { errors: normalize_errors(errors) }, status: status - end - - def render_not_found(message = 'Theme not found') - render json: { errors: [message] }, status: :not_found - end - end - end - end - end - end -end \ No newline at end of file diff --git a/app/controllers/spree/api/v2/platform/ai/themes_controller.rb b/app/controllers/spree/api/v2/platform/ai/themes_controller.rb deleted file mode 100644 index c19f484..0000000 --- a/app/controllers/spree/api/v2/platform/ai/themes_controller.rb +++ /dev/null @@ -1,100 +0,0 @@ -module Spree - module Api - module V2 - module Platform - module Ai - class ThemesController < BaseController - def create - store = store_from_params - return render_not_found('Store not found') if store.blank? - - service = Spree::Olitt::CloneStore::AiTheme::Sync.new(store: store) - theme = service.upsert_theme(theme_params) - return render_errors(service.errors) if theme.nil? - - render json: service.theme_payload(theme), status: :created - end - - def show - theme = find_theme - return render_not_found unless theme - - render json: sync_service.theme_payload(theme), status: :ok - end - - def preview - theme = find_theme - return render_not_found unless theme - - token = sync_service.preview_theme(theme) - return render_errors(sync_service.errors) if token.nil? - - render json: sync_service.theme_payload(theme), status: :accepted - end - - def publish - theme = find_theme - return render_not_found unless theme - - published = sync_service.publish_theme(theme) - return render_errors(sync_service.errors) if published.nil? - - render json: sync_service.theme_payload(published), status: :ok - end - - private - - def sync_service - @sync_service ||= Spree::Olitt::CloneStore::AiTheme::Sync.new(store: store_from_params) - end - - def store_from_params - @store_from_params ||= Spree::Store.find_by(id: theme_params[:store_id] || params[:store_id]) - end - - def find_theme - theme_id = params[:theme_theme_id] || params[:theme_id] || theme_params[:theme_theme_id] || theme_params[:theme_id] || theme_params[:id] - return nil if theme_id.blank? - - Spree::Theme.find_by(id: theme_id) - end - - def theme_params - params.fetch(:theme, params).permit( - :id, - :theme_id, - :store_id, - :name, - :default, - :prompt, - :status, - :version, - :checksum, - spec: {}, - pages: [ - :id, :type, :kind, :class_name, :name, :title, :slug, :meta_title, :meta_description, - :meta_keywords, :visible, - { sections: [ - :id, :type, :kind, :class_name, :name, :position, - { content: {}, settings: {}, preferences: {}, blocks: [ - :id, :type, :kind, :class_name, :name, :position, - { content: {}, settings: {}, preferences: {} } - ] } - ] } - ] - ).to_h.symbolize_keys - end - - def render_errors(errors, status: :unprocessable_entity) - render json: { errors: normalize_errors(errors) }, status: status - end - - def render_not_found(message = 'Theme not found') - render json: { errors: [message] }, status: :not_found - end - end - end - end - end - end -end \ No newline at end of file From 1c64a3ca9e9f79ba44913b17d82828d76c3b479c Mon Sep 17 00:00:00 2001 From: kipsang Date: Mon, 4 May 2026 10:24:44 +0300 Subject: [PATCH 08/10] feat: update shipping and stock location duplicators to use vendor-specific queries for uniqueness checks --- .../shipping_categories_duplicator.rb | 2 +- .../duplicators/stock_locations_duplicator.rb | 4 ++-- .../shipping_categories_duplicator_spec.rb | 8 ++++---- .../stock_locations_duplicator_spec.rb | 16 ++++++++-------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/app/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator.rb b/app/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator.rb index 7bd5381..afa6fe7 100644 --- a/app/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator.rb +++ b/app/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator.rb @@ -61,7 +61,7 @@ def cache_shipping_category(old_shipping_category:, new_shipping_category:) def unique_shipping_category_name(shipping_category:, new_shipping_category:) unique_value(base_value: shipping_category.name) do |candidate| - Spree::ShippingCategory.where.not(id: new_shipping_category.id).where(name: candidate).exists? + vendor_shipping_categories.where.not(id: new_shipping_category.id).where(name: candidate).exists? end end diff --git a/app/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator.rb b/app/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator.rb index aade3ff..6de4137 100644 --- a/app/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator.rb +++ b/app/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator.rb @@ -126,13 +126,13 @@ def unique_stock_location_name(old_location:, new_location:) base_name = old_location.name.presence || @new_store.name unique_value(base_value: base_name) do |candidate| - Spree::StockLocation.where.not(id: new_location.id).where(name: candidate).exists? + @vendor.stock_locations.where.not(id: new_location.id).where(name: candidate).exists? end end def unique_fallback_stock_location_name(new_location:) unique_value(base_value: DEFAULT_STOCK_LOCATION_NAME) do |candidate| - Spree::StockLocation.where.not(id: new_location.id).where(name: candidate).exists? + @vendor.stock_locations.where.not(id: new_location.id).where(name: candidate).exists? end end diff --git a/spec/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator_spec.rb b/spec/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator_spec.rb index 4e4f929..67a84cc 100644 --- a/spec/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator_spec.rb +++ b/spec/services/spree/olitt/clone_store/duplicators/shipping_categories_duplicator_spec.rb @@ -15,15 +15,15 @@ module Duplicators vendor = instance_double('Spree::Vendor', id: 19) new_shipping_category = instance_double('Spree::ShippingCategory') vendor_shipping_categories = instance_double('ActiveRecord::Relation') - shipping_categories_scope = instance_double('ActiveRecord::Relation') + vendor_shipping_categories_without_current = instance_double('ActiveRecord::Relation') shipping_categories_named_scope = instance_double('ActiveRecord::Relation') allow(products_relation).to receive(:includes).with(:shipping_category).and_return([product]) allow(Spree::ShippingCategory).to receive(:where).with(vendor_id: 19).and_return(vendor_shipping_categories) allow(vendor_shipping_categories).to receive(:find_by).with(name: 'Physical Goods').and_return(nil) - allow(Spree::ShippingCategory).to receive(:where).and_return(shipping_categories_scope) - allow(shipping_categories_scope).to receive(:not).and_return(shipping_categories_scope) - allow(shipping_categories_scope).to receive(:where).with(name: 'Physical Goods').and_return(shipping_categories_named_scope) + allow(vendor_shipping_categories).to receive(:where).and_return(vendor_shipping_categories_without_current) + allow(vendor_shipping_categories_without_current).to receive(:not).and_return(vendor_shipping_categories_without_current) + allow(vendor_shipping_categories_without_current).to receive(:where).with(name: 'Physical Goods').and_return(shipping_categories_named_scope) allow(shipping_categories_named_scope).to receive(:exists?).and_return(false) allow(Spree::ShippingCategory).to receive(:new).and_return(new_shipping_category) diff --git a/spec/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator_spec.rb b/spec/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator_spec.rb index 9f8077a..b5cc768 100644 --- a/spec/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator_spec.rb +++ b/spec/services/spree/olitt/clone_store/duplicators/stock_locations_duplicator_spec.rb @@ -34,16 +34,16 @@ module Duplicators vendor_stock_locations = instance_double('ActiveRecord::Relation') vendor = instance_double('Spree::Vendor', id: 9, name: 'vendor@example.com', stock_locations: vendor_stock_locations) new_location = instance_double('Spree::StockLocation') - stock_locations_scope = instance_double('ActiveRecord::Relation') + vendor_stock_locations_without_current = instance_double('ActiveRecord::Relation') stock_locations_named_scope = instance_double('ActiveRecord::Relation') allow(stock_locations_relation).to receive(:includes).with(:country, :state).and_return(stock_locations_relation) allow(stock_locations_relation).to receive(:order).with(default: :desc, id: :asc).and_return([old_location]) allow(vendor_stock_locations).to receive(:order).with(default: :desc, id: :asc).and_return([]) allow(vendor_stock_locations).to receive(:find_by).with(name: 'Main Warehouse').and_return(nil) - allow(Spree::StockLocation).to receive(:where).and_return(stock_locations_scope) - allow(stock_locations_scope).to receive(:not).and_return(stock_locations_scope) - allow(stock_locations_scope).to receive(:where).with(name: 'Main Warehouse').and_return(stock_locations_named_scope) + allow(vendor_stock_locations).to receive(:where).and_return(vendor_stock_locations_without_current) + allow(vendor_stock_locations_without_current).to receive(:not).and_return(vendor_stock_locations_without_current) + allow(vendor_stock_locations_without_current).to receive(:where).with(name: 'Main Warehouse').and_return(stock_locations_named_scope) allow(stock_locations_named_scope).to receive(:exists?).and_return(false) allow(Spree::StockLocation).to receive(:new).and_return(new_location) @@ -84,15 +84,15 @@ module Duplicators vendor_stock_locations = instance_double('ActiveRecord::Relation') vendor = instance_double('Spree::Vendor', id: 9, name: 'vendor@example.com', stock_locations: vendor_stock_locations) new_location = instance_double('Spree::StockLocation') - stock_locations_scope = instance_double('ActiveRecord::Relation') + vendor_stock_locations_without_current = instance_double('ActiveRecord::Relation') stock_locations_named_scope = instance_double('ActiveRecord::Relation') allow(products_relation).to receive(:includes).with(variants_including_master: { stock_items: :stock_location }).and_return([]) allow(vendor_stock_locations).to receive(:order).with(default: :desc, id: :asc).and_return([]) allow(vendor_stock_locations).to receive(:find_by).with(default: true).and_return(nil) - allow(Spree::StockLocation).to receive(:where).and_return(stock_locations_scope) - allow(stock_locations_scope).to receive(:not).and_return(stock_locations_scope) - allow(stock_locations_scope).to receive(:where).with(name: 'US location').and_return(stock_locations_named_scope) + allow(vendor_stock_locations).to receive(:where).and_return(vendor_stock_locations_without_current) + allow(vendor_stock_locations_without_current).to receive(:not).and_return(vendor_stock_locations_without_current) + allow(vendor_stock_locations_without_current).to receive(:where).with(name: 'US location').and_return(stock_locations_named_scope) allow(stock_locations_named_scope).to receive(:exists?).and_return(false) allow(Spree::StockLocation).to receive(:new).and_return(new_location) From ec44a1a129855393d3b9f3081e276b8b47b3cc6f Mon Sep 17 00:00:00 2001 From: kipsang Date: Mon, 4 May 2026 11:49:16 +0300 Subject: [PATCH 09/10] feat: implement CloneStoresController and associated specs for clone store functionality --- .../v2/platform/clone_stores_controller.rb | 36 +++++++++++++++++++ .../spree/olitt/clone_store/clone_request.rb | 2 +- .../platform/clone_stores_controller_spec.rb | 29 +++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/app/controllers/spree/api/v2/platform/clone_stores_controller.rb b/app/controllers/spree/api/v2/platform/clone_stores_controller.rb index 3c3be36..b70f07f 100644 --- a/app/controllers/spree/api/v2/platform/clone_stores_controller.rb +++ b/app/controllers/spree/api/v2/platform/clone_stores_controller.rb @@ -5,6 +5,8 @@ module Platform class CloneStoresController < Spree::Olitt::CloneStore::CloneStoreController skip_forgery_protection + attr_reader :current_api_key + rescue_from Doorkeeper::Errors::DoorkeeperError, with: :render_unauthorized rescue_from ActiveRecord::RecordNotUnique, with: :render_bad_request_exception rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid @@ -25,11 +27,17 @@ def show private def authorize_clone_store_request! + if api_key_header_present? + authenticate_secret_key! + return + end + scopes = action_name == 'show' ? %i[read admin] : %i[write admin] doorkeeper_authorize!(*scopes) end def authorize_superadmin_user_token! + return if current_api_key.present? return if spree_current_user.nil? return if superuser_with_global_admin_role?(spree_current_user) @@ -45,11 +53,39 @@ def render_unauthorized(_exception) end def validate_token_client + return if api_key_header_present? return if doorkeeper_token.nil? raise Doorkeeper::Errors::DoorkeeperError if doorkeeper_token.application.nil? end + def authenticate_secret_key! + @current_api_key = Spree::ApiKey.find_by_secret_token(extract_api_key) + @current_api_key = nil if @current_api_key && (!current_store.present? || @current_api_key.store_id != current_store.id) + + unless @current_api_key + render_api_error('Valid secret API key required', :unauthorized) + return false + end + + touch_api_key_if_needed(@current_api_key) + true + end + + def touch_api_key_if_needed(api_key) + return if api_key.last_used_at.present? && api_key.last_used_at > 1.hour.ago + + Spree::ApiKeys::MarkAsUsed.perform_later(api_key.id, Time.current) + end + + def extract_api_key + request.headers['X-Spree-Api-Key'].presence + end + + def api_key_header_present? + extract_api_key.present? + end + def render_api_error(message, status) render json: { error: message }, status: status end diff --git a/app/models/spree/olitt/clone_store/clone_request.rb b/app/models/spree/olitt/clone_store/clone_request.rb index 5f2c1e1..ceec195 100644 --- a/app/models/spree/olitt/clone_store/clone_request.rb +++ b/app/models/spree/olitt/clone_store/clone_request.rb @@ -18,7 +18,7 @@ class CloneRequest < ::ApplicationRecord belongs_to :source_store, class_name: 'Spree::Store' belongs_to :store, class_name: 'Spree::Store', optional: true belongs_to :vendor, class_name: 'Spree::Vendor', optional: true - belongs_to :admin_user, class_name: Spree.admin_user_class.to_s, foreign_key: :user_id, optional: true, inverse_of: :clone_requests + belongs_to :admin_user, class_name: Spree.admin_user_class.to_s, foreign_key: :user_id, optional: true belongs_to :role_user, class_name: 'Spree::RoleUser', optional: true validates :store_name, :store_url, :store_code, :store_mail_from_address, :vendor_email, :vendor_password, presence: true diff --git a/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb b/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb index 643dec8..359ec55 100644 --- a/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb +++ b/spec/controllers/spree/api/v2/platform/clone_stores_controller_spec.rb @@ -65,4 +65,33 @@ 'next_path' => '/admin' ) end + + describe 'secret API key authentication' do + let(:api_key) { instance_double('Spree::ApiKey', id: 44, store_id: source_store.id, last_used_at: nil) } + + it 'accepts X-Spree-Api-Key without doorkeeper auth' do + request.headers['X-Spree-Api-Key'] = 'sk_test_123' + + allow(controller).to receive(:current_store).and_return(source_store) + allow(Spree::ApiKey).to receive(:find_by_secret_token).with('sk_test_123').and_return(api_key) + allow(Spree::ApiKeys::MarkAsUsed).to receive(:perform_later) + expect(controller).not_to receive(:doorkeeper_authorize!) + + controller.send(:authorize_clone_store_request!) + + expect(controller.send(:current_api_key)).to eq(api_key) + end + + it 'renders unauthorized for an invalid X-Spree-Api-Key' do + request.headers['X-Spree-Api-Key'] = 'sk_invalid' + + allow(controller).to receive(:current_store).and_return(source_store) + allow(Spree::ApiKey).to receive(:find_by_secret_token).with('sk_invalid').and_return(nil) + + controller.send(:authorize_clone_store_request!) + + expect(response).to have_http_status(:unauthorized) + expect(JSON.parse(response.body)).to eq('error' => 'Valid secret API key required') + end + end end \ No newline at end of file From 2fe0118f1dd8da96c9aba6636d86ab7f754dbebc Mon Sep 17 00:00:00 2001 From: kipsang Date: Mon, 4 May 2026 11:58:55 +0300 Subject: [PATCH 10/10] feat: update authenticate_secret_key method to use token digest for API key validation --- .../spree/api/v2/platform/clone_stores_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/spree/api/v2/platform/clone_stores_controller.rb b/app/controllers/spree/api/v2/platform/clone_stores_controller.rb index b70f07f..c70a2d3 100644 --- a/app/controllers/spree/api/v2/platform/clone_stores_controller.rb +++ b/app/controllers/spree/api/v2/platform/clone_stores_controller.rb @@ -60,8 +60,8 @@ def validate_token_client end def authenticate_secret_key! - @current_api_key = Spree::ApiKey.find_by_secret_token(extract_api_key) - @current_api_key = nil if @current_api_key && (!current_store.present? || @current_api_key.store_id != current_store.id) + @current_api_key = Spree::ApiKey.find_by(token_digest: Spree::ApiKey.compute_token_digest(extract_api_key)) + @current_api_key = nil if @current_api_key && (current_store.blank? || @current_api_key.store_id != current_store.id) unless @current_api_key render_api_error('Valid secret API key required', :unauthorized)