From 741c548d269e68f5dcdafc0e6d5b010a99d541d7 Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Wed, 22 Oct 2025 16:17:17 -0500 Subject: [PATCH] CP-11042 partnership features updated (#26) * Add partnership template authorization and ability system * Update template authorization to support partnership context * Add request context-based authorization for API access * Implement hybrid partnership/account authorization logic * Add submission authorization conditions for partnerships * Support global partnership template access * Add template cloning services for partnership workflows * Update template cloning to require explicit target parameters, to allow for cloning for either account or from partnership * Add Templates::CloneToAccount service for partnership to account cloning * Add Templates::CloneToPartnership service for global to partnership cloning * Add logic to detect account vs partnership template cloning with validation * Add folder assignment logic for cloned templates * Add external authentication and partnership support * Update ExternalAuthService to support partnership OR account authentication * Implement user assignment to accounts when partnership context is provided * Support pure partnership authentication without account assignment * Update API controllers for partnership template support * Add partnership request context to API base controller * Update submissions controller to support partnership templates * Add partnership template cloning to templates clone controller * Refactor template controller webhook logic to reduce complexity * Support external_account_id parameter for partnership workflows * Update web controllers and views for partnership template support * Add tests * erb_lint fixes * add local claude file * shared concern for handling partnership context * remove overly permissive case * global templates should be available for partnerships and accounts * pass through access context in vue * add tests * add partnership context and tests to submissions * add token refresh as last resort for a corrupted token --- .gitignore | 3 +- app/controllers/api/api_base_controller.rb | 7 +- app/controllers/api/submissions_controller.rb | 2 +- .../api/templates_clone_controller.rb | 72 +++++++- app/controllers/api/templates_controller.rb | 42 ++++- .../api/token_refresh_controller.rb | 31 ++++ .../concerns/partnership_context.rb | 21 +++ .../template_documents_controller.rb | 1 + app/controllers/templates_controller.rb | 21 ++- .../templates_form_preview_controller.rb | 5 + app/javascript/template_builder/builder.vue | 60 +++++- app/javascript/template_builder/dropzone.vue | 2 +- app/javascript/template_builder/upload.vue | 38 +++- app/models/export_location.rb | 4 + app/services/external_auth_service.rb | 49 ++++- app/services/token_refresh_service.rb | 32 ++++ app/views/submit_form/show.html.erb | 17 +- .../templates_form_preview/show.html.erb | 4 +- config/routes.rb | 6 + lib/abilities/submission_conditions.rb | 88 +++++++++ lib/abilities/template_conditions.rb | 53 ++++-- lib/ability.rb | 12 +- lib/submitters/form_configs.rb | 4 +- lib/templates/clone.rb | 61 +++++- lib/templates/clone_to_account.rb | 63 +++++++ lib/templates/clone_to_partnership.rb | 69 +++++++ .../concerns/partnership_context_spec.rb | 130 +++++++++++++ spec/factories/submissions.rb | 3 +- spec/factories/templates.rb | 18 ++ spec/factories/users.rb | 4 + .../abilities/submission_conditions_spec.rb | 173 ++++++++++++++++++ .../lib/abilities/template_conditions_spec.rb | 89 +++++++++ spec/lib/templates/clone_spec.rb | 68 +++++++ spec/lib/templates/clone_to_account_spec.rb | 66 +++++++ .../templates/clone_to_partnership_spec.rb | 171 +++++++++++++++++ spec/requests/external_auth_spec.rb | 39 ++++ spec/requests/templates_spec.rb | 35 ++++ spec/services/external_auth_service_spec.rb | 49 +++++ spec/services/token_refresh_service_spec.rb | 68 +++++++ 39 files changed, 1607 insertions(+), 73 deletions(-) create mode 100644 app/controllers/api/token_refresh_controller.rb create mode 100644 app/controllers/concerns/partnership_context.rb create mode 100644 app/services/token_refresh_service.rb create mode 100644 lib/abilities/submission_conditions.rb create mode 100644 lib/templates/clone_to_account.rb create mode 100644 lib/templates/clone_to_partnership.rb create mode 100644 spec/controllers/concerns/partnership_context_spec.rb create mode 100644 spec/lib/abilities/submission_conditions_spec.rb create mode 100644 spec/lib/abilities/template_conditions_spec.rb create mode 100644 spec/lib/templates/clone_spec.rb create mode 100644 spec/lib/templates/clone_to_account_spec.rb create mode 100644 spec/lib/templates/clone_to_partnership_spec.rb create mode 100644 spec/services/token_refresh_service_spec.rb 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 @@ />