diff --git a/.gitignore b/.gitignore index 012a440f..03d482a3 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ yarn-debug.log* /ee dump.rdb .aider* -.kilocode/* \ No newline at end of file +.kilocode/* +CLAUDE.local.md diff --git a/app/controllers/api/api_base_controller.rb b/app/controllers/api/api_base_controller.rb index 05e56eb3..07dc2def 100644 --- a/app/controllers/api/api_base_controller.rb +++ b/app/controllers/api/api_base_controller.rb @@ -4,6 +4,7 @@ module Api class ApiBaseController < ActionController::API include ActiveStorage::SetCurrent include Pagy::Backend + include PartnershipContext DEFAULT_LIMIT = 10 MAX_LIMIT = 100 @@ -45,11 +46,13 @@ module Api return 'Not authorized' unless error.subject.respond_to?(:account_id) linked_account_record_exists = - if current_user.account.testing? + if current_user.account&.testing? current_user.account.linked_account_accounts.where(account_type: 'testing') .exists?(account_id: error.subject.account_id) - else + elsif current_user.account current_user.account.testing_accounts.exists?(id: error.subject.account_id) + else + false end return 'Not authorized' unless linked_account_record_exists diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index 1bef7f4e..923d9651 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -187,7 +187,7 @@ module Api def submissions_params permitted_attrs = [ :send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last, - :expire_at, :name, + :expire_at, :name, :external_account_id, { message: %i[subject body], submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role, diff --git a/app/controllers/api/templates_clone_controller.rb b/app/controllers/api/templates_clone_controller.rb index 2619a243..dc40b70e 100644 --- a/app/controllers/api/templates_clone_controller.rb +++ b/app/controllers/api/templates_clone_controller.rb @@ -5,37 +5,91 @@ module Api load_and_authorize_resource :template def create + # Handle cloning from partnership templates to specific accounts + if params[:external_account_id].present? && @template.partnership_id.present? + return clone_from_partnership_to_account + end + authorize!(:create, @template) + cloned_template = clone_template_with_service(Templates::Clone, @template) + finalize_and_render_response(cloned_template) + end + + private + + def clone_from_partnership_to_account + cloned_template = Templates::CloneToAccount.call( + @template, + external_account_id: params[:external_account_id], + current_user: current_user, + author: current_user, + name: params[:name], + external_id: params[:external_id].presence || params[:application_key], + folder_name: params[:folder_name] + ) + + cloned_template.source = :api + finalize_and_render_response(cloned_template) + rescue ArgumentError => e + if e.message.include?('Unauthorized') + render json: { error: e.message }, status: :forbidden + elsif e.message.include?('must be a partnership template') + render json: { error: e.message }, status: :unprocessable_entity + else + render json: { error: e.message }, status: :bad_request + end + rescue ActiveRecord::RecordNotFound => e + render json: { error: e.message }, status: :not_found + end + + def clone_template_with_service(service_class, template, **extra_args) ActiveRecord::Associations::Preloader.new( - records: [@template], + records: [template], associations: [schema_documents: :preview_images_attachments] ).call - cloned_template = Templates::Clone.call( - @template, + # Determine target for same-type cloning (clone to same ownership type as original) + target_args = if template.account_id.present? + { target_account: template.account } + elsif template.partnership_id.present? + { target_partnership: template.partnership } + else + {} + end + + cloned_template = service_class.call( + template, author: current_user, name: params[:name], external_id: params[:external_id].presence || params[:application_key], - folder_name: params[:folder_name] + folder_name: params[:folder_name], + **target_args, + **extra_args ) cloned_template.source = :api + cloned_template + end + def finalize_and_render_response(cloned_template) schema_documents = Templates::CloneAttachments.call(template: cloned_template, original_template: @template, documents: params[:documents]) cloned_template.save! - WebhookUrls.for_account_id(cloned_template.account_id, 'template.created').each do |webhook_url| - SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id, - 'webhook_url_id' => webhook_url.id) - end - + enqueue_webhooks(cloned_template) SearchEntries.enqueue_reindex(cloned_template) render json: Templates::SerializeForApi.call(cloned_template, schema_documents) end + + def enqueue_webhooks(template) + WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url| + SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id, + 'webhook_url_id' => webhook_url.id) + end + end end end diff --git a/app/controllers/api/templates_controller.rb b/app/controllers/api/templates_controller.rb index aed33d35..682e6e24 100644 --- a/app/controllers/api/templates_controller.rb +++ b/app/controllers/api/templates_controller.rb @@ -70,11 +70,7 @@ module Api @template.update!(template_params) SearchEntries.enqueue_reindex(@template) - - WebhookUrls.for_account_id(@template.account_id, 'template.updated').each do |webhook_url| - SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id, - 'webhook_url_id' => webhook_url.id) - end + enqueue_template_updated_webhooks render json: @template.as_json(only: %i[id updated_at]) end @@ -151,13 +147,30 @@ module Api def build_template template = Template.new - template.account = current_account template.author = current_user - template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) template.name = params[:name] || 'Untitled Template' template.external_id = params[:external_id] if params[:external_id].present? template.source = :api template.submitters = params[:submitters] if params[:submitters].present? + + # Handle partnership vs account template creation + if params[:external_partnership_id].present? + partnership = Partnership.find_by(external_partnership_id: params[:external_partnership_id]) + if partnership.blank? + raise ActiveRecord::RecordNotFound, "Partnership not found: #{params[:external_partnership_id]}" + end + + template.partnership = partnership + template.folder = TemplateFolders.find_or_create_by_name( + current_user, + params[:folder_name], + partnership: partnership + ) + else + template.account = current_account + template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name]) + end + template end @@ -199,9 +212,18 @@ module Api end def enqueue_template_created_webhooks(template) - WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url| - SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id, - 'webhook_url_id' => webhook_url.id) + enqueue_template_webhooks(template, 'template.created', SendTemplateCreatedWebhookRequestJob) + end + + def enqueue_template_updated_webhooks + enqueue_template_webhooks(@template, 'template.updated', SendTemplateUpdatedWebhookRequestJob) + end + + def enqueue_template_webhooks(template, event_type, job_class) + return if template.account_id.blank? + + WebhookUrls.for_account_id(template.account_id, event_type).each do |webhook_url| + job_class.perform_async('template_id' => template.id, 'webhook_url_id' => webhook_url.id) end end diff --git a/app/controllers/api/token_refresh_controller.rb b/app/controllers/api/token_refresh_controller.rb new file mode 100644 index 00000000..13394e29 --- /dev/null +++ b/app/controllers/api/token_refresh_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Api + class TokenRefreshController < ApiBaseController + skip_before_action :authenticate_via_token! + skip_authorization_check + + def create + service = TokenRefreshService.new(token_refresh_params) + new_token = service.refresh_token + + if new_token + render json: { access_token: new_token }, status: :ok + else + render json: { error: 'Unable to refresh token. User may not exist.' }, status: :unprocessable_entity + end + rescue ArgumentError => e + render json: { error: e.message }, status: :bad_request + rescue StandardError => e + Rails.logger.error "Token refresh error: #{e.message}" + render json: { error: 'Internal server error during token refresh' }, status: :internal_server_error + end + + private + + def token_refresh_params + params.permit(:account, :partnership, :external_account_id, user: %i[external_id email first_name last_name]) + .to_h.deep_symbolize_keys + end + end +end diff --git a/app/controllers/concerns/partnership_context.rb b/app/controllers/concerns/partnership_context.rb new file mode 100644 index 00000000..eb3d6b8b --- /dev/null +++ b/app/controllers/concerns/partnership_context.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module PartnershipContext + extend ActiveSupport::Concern + + private + + def current_ability + @current_ability ||= Ability.new(current_user, partnership_request_context) + end + + def partnership_request_context + return nil if params[:accessible_partnership_ids].blank? + + { + accessible_partnership_ids: Array.wrap(params[:accessible_partnership_ids]).map(&:to_i), + external_account_id: params[:external_account_id], + external_partnership_id: params[:external_partnership_id] + } + end +end diff --git a/app/controllers/template_documents_controller.rb b/app/controllers/template_documents_controller.rb index 7aaa039c..207d3291 100644 --- a/app/controllers/template_documents_controller.rb +++ b/app/controllers/template_documents_controller.rb @@ -2,6 +2,7 @@ class TemplateDocumentsController < ApplicationController include IframeAuthentication + include PartnershipContext skip_before_action :verify_authenticity_token skip_before_action :authenticate_via_token! diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index c000263d..b5332622 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -3,6 +3,7 @@ class TemplatesController < ApplicationController include PrefillFieldsHelper include IframeAuthentication + include PartnershipContext skip_before_action :verify_authenticity_token skip_before_action :authenticate_via_token!, only: [:update] @@ -51,7 +52,8 @@ class TemplatesController < ApplicationController methods: %i[metadata signed_uuid], include: { preview_images: { methods: %i[url metadata filename] } } ), - available_prefill_fields: @available_prefill_fields + available_prefill_fields: @available_prefill_fields, + partnership_context: partnership_request_context ).to_json render :edit, layout: 'plain' @@ -64,9 +66,20 @@ class TemplatesController < ApplicationController associations: [schema_documents: :preview_images_attachments] ).call - @template = Templates::Clone.call(@base_template, author: current_user, - name: params.dig(:template, :name), - folder_name: params[:folder_name]) + # Determine target for same-type cloning (clone to same ownership type as original) + target_args = if @base_template.account_id.present? + { target_account: @base_template.account } + elsif @base_template.partnership_id.present? + { target_partnership: @base_template.partnership } + else + {} + end + + @template = Templates::Clone.call(@base_template, + author: current_user, + name: params.dig(:template, :name), + folder_name: params[:folder_name], + **target_args) else @template = Template.new(template_params) if @template.nil? @template.author = current_user diff --git a/app/controllers/templates_form_preview_controller.rb b/app/controllers/templates_form_preview_controller.rb index 5c4f23c6..642d487d 100644 --- a/app/controllers/templates_form_preview_controller.rb +++ b/app/controllers/templates_form_preview_controller.rb @@ -1,8 +1,13 @@ # frozen_string_literal: true class TemplatesFormPreviewController < ApplicationController + include IframeAuthentication + include PartnershipContext + layout 'form' + skip_before_action :authenticate_via_token! + before_action :authenticate_from_referer load_and_authorize_resource :template def show diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index b05f1d67..e3ecb684 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -64,7 +64,7 @@ />