+
diff --git a/config/routes.rb b/config/routes.rb
index eddde784..2ea9dc02 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -61,6 +61,12 @@ Rails.application.routes.draw do
post :user_token
end
end
+
+ resources :token_refresh, only: [] do
+ collection do
+ post :create
+ end
+ end
end
resources :export, controller: 'export' do
diff --git a/db/migrate/20250924174100_rename_account_groups_to_partnerships.rb b/db/migrate/20250924174100_rename_account_groups_to_partnerships.rb
new file mode 100644
index 00000000..9075cec8
--- /dev/null
+++ b/db/migrate/20250924174100_rename_account_groups_to_partnerships.rb
@@ -0,0 +1,20 @@
+class RenameAccountGroupsToPartnerships < ActiveRecord::Migration[8.0]
+ def change
+ # Rename the table
+ rename_table :account_groups, :partnerships
+
+ # Rename the foreign key columns in other tables
+ rename_column :templates, :account_group_id, :partnership_id
+ rename_column :template_folders, :account_group_id, :partnership_id
+
+ # Add global_partnership_id to export_locations
+ add_column :export_locations, :global_partnership_id, :integer
+
+ # Remove partnership relationships since both users and accounts use API context now
+ remove_column :users, :account_group_id, :bigint
+ remove_column :accounts, :account_group_id, :bigint
+
+ # Rename the external ID column to match new naming
+ rename_column :partnerships, :external_account_group_id, :external_partnership_id
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 626e9dac..427e4c40 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do
+ActiveRecord::Schema[8.0].define(version: 2025_09_24_174100) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@@ -43,14 +43,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do
t.index ["account_id"], name: "index_account_configs_on_account_id"
end
- create_table "account_groups", force: :cascade do |t|
- t.integer "external_account_group_id", null: false
- t.string "name", null: false
- t.datetime "created_at", null: false
- t.datetime "updated_at", null: false
- t.index ["external_account_group_id"], name: "index_account_groups_on_external_account_group_id", unique: true
- end
-
create_table "account_linked_accounts", force: :cascade do |t|
t.integer "account_id", null: false
t.integer "linked_account_id", null: false
@@ -71,8 +63,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do
t.string "uuid", null: false
t.datetime "archived_at"
t.integer "external_account_id"
- t.bigint "account_group_id"
- t.index ["account_group_id"], name: "index_accounts_on_account_group_id"
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
t.index ["uuid"], name: "index_accounts_on_uuid", unique: true
end
@@ -238,6 +228,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do
t.datetime "updated_at", null: false
t.jsonb "extra_params", default: {}, null: false
t.string "submissions_endpoint"
+ t.integer "global_partnership_id"
end
create_table "oauth_access_grants", force: :cascade do |t|
@@ -282,6 +273,14 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
end
+ create_table "partnerships", force: :cascade do |t|
+ t.integer "external_partnership_id", null: false
+ t.string "name", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["external_partnership_id"], name: "index_partnerships_on_external_partnership_id", unique: true
+ end
+
create_table "search_entries", force: :cascade do |t|
t.string "record_type", null: false
t.bigint "record_id", null: false
@@ -377,10 +376,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do
t.datetime "archived_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
- t.bigint "account_group_id"
- t.index ["account_group_id"], name: "index_template_folders_on_account_group_id"
+ t.bigint "partnership_id"
t.index ["account_id"], name: "index_template_folders_on_account_id"
t.index ["author_id"], name: "index_template_folders_on_author_id"
+ t.index ["partnership_id"], name: "index_template_folders_on_partnership_id"
end
create_table "template_sharings", force: :cascade do |t|
@@ -410,14 +409,14 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do
t.text "preferences", null: false
t.boolean "shared_link", default: false, null: false
t.text "external_data_fields"
- t.bigint "account_group_id"
- t.index ["account_group_id"], name: "index_templates_on_account_group_id"
+ t.bigint "partnership_id"
t.index ["account_id", "folder_id", "id"], name: "index_templates_on_account_id_and_folder_id_and_id", where: "(archived_at IS NULL)"
t.index ["account_id", "id"], name: "index_templates_on_account_id_and_id_archived", where: "(archived_at IS NOT NULL)"
t.index ["account_id"], name: "index_templates_on_account_id"
t.index ["author_id"], name: "index_templates_on_author_id"
t.index ["external_id"], name: "index_templates_on_external_id"
t.index ["folder_id"], name: "index_templates_on_folder_id"
+ t.index ["partnership_id"], name: "index_templates_on_partnership_id"
t.index ["slug"], name: "index_templates_on_slug", unique: true
end
@@ -457,8 +456,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do
t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false, null: false
t.integer "external_user_id"
- t.bigint "account_group_id"
- t.index ["account_group_id"], name: "index_users_on_account_group_id"
t.index ["account_id"], name: "index_users_on_account_id"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["external_user_id"], name: "index_users_on_external_user_id", unique: true
@@ -484,7 +481,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do
add_foreign_key "account_configs", "accounts"
add_foreign_key "account_linked_accounts", "accounts"
add_foreign_key "account_linked_accounts", "accounts", column: "linked_account_id"
- add_foreign_key "accounts", "account_groups"
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters"
@@ -503,16 +499,15 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do
add_foreign_key "submissions", "users", column: "created_by_user_id"
add_foreign_key "submitters", "submissions"
add_foreign_key "template_accesses", "templates"
- add_foreign_key "template_folders", "account_groups"
add_foreign_key "template_folders", "accounts"
+ add_foreign_key "template_folders", "partnerships"
add_foreign_key "template_folders", "users", column: "author_id"
add_foreign_key "template_sharings", "templates"
- add_foreign_key "templates", "account_groups"
add_foreign_key "templates", "accounts"
+ add_foreign_key "templates", "partnerships"
add_foreign_key "templates", "template_folders", column: "folder_id"
add_foreign_key "templates", "users", column: "author_id"
add_foreign_key "user_configs", "users"
- add_foreign_key "users", "account_groups"
add_foreign_key "users", "accounts"
add_foreign_key "webhook_urls", "accounts"
end
diff --git a/lib/abilities/submission_conditions.rb b/lib/abilities/submission_conditions.rb
new file mode 100644
index 00000000..746e684d
--- /dev/null
+++ b/lib/abilities/submission_conditions.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+module Abilities
+ # Provides authorization conditions for submission access control.
+ # Only account users can access submissions (partnership users create templates).
+ # Supports partnership inheritance and global template access patterns.
+ module SubmissionConditions
+ module_function
+
+ def collection(user, request_context: nil)
+ return [] if user.account_id.blank?
+
+ submissions_for_user(user, request_context)
+ end
+
+ def entity(submission, user:, request_context: nil)
+ # Only account users can access submissions
+ return false if user.account_id.blank?
+
+ # User can access their own account's submissions
+ return true if submission.account_id == user.account_id
+
+ if submission.template_id.present?
+ template = submission.template || Template.find_by(id: submission.template_id)
+ return false unless template
+
+ return true if user_can_access_template?(user, template, request_context)
+ end
+ false
+ end
+
+ def submissions_for_user(user, request_context = nil)
+ accessible_template_ids = accessible_template_ids(request_context)
+
+ Submission.where(
+ 'submissions.account_id = ? OR submissions.template_id IN (?)',
+ user.account_id,
+ accessible_template_ids
+ )
+ end
+
+ def accessible_template_ids(request_context = nil)
+ template_ids = []
+
+ # Add templates from partnership context (if provided via API)
+ if request_context&.dig(:accessible_partnership_ids).present?
+ accessible_partnership_ids = request_context[:accessible_partnership_ids]
+ partnership_ids = Partnership.where(external_partnership_id: accessible_partnership_ids).pluck(:id)
+ template_ids += Template.where(partnership_id: partnership_ids).pluck(:id)
+ end
+
+ # Add templates from global partnership (accessible to everyone)
+ if ExportLocation.global_partnership_id.present?
+ template_ids += Template.where(partnership_id: ExportLocation.global_partnership_id).pluck(:id)
+ end
+
+ template_ids.uniq
+ end
+
+ def user_can_access_template?(user, template, request_context = nil)
+ # User can access templates from their account
+ return true if template.account_id == user.account_id
+
+ # Check partnership context access
+ return true if partnership_context_accessible?(template, request_context)
+
+ # Check global partnership access
+ return true if global_template_accessible?(template)
+
+ false
+ end
+
+ def partnership_context_accessible?(template, request_context)
+ return false if request_context&.dig(:accessible_partnership_ids).blank?
+ return false if template.partnership_id.blank?
+
+ accessible_partnership_ids = request_context[:accessible_partnership_ids]
+ accessible_partnerships = Partnership.where(external_partnership_id: accessible_partnership_ids)
+
+ accessible_partnerships.exists?(id: template.partnership_id)
+ end
+
+ def global_template_accessible?(template)
+ ExportLocation.global_partnership_id.present? &&
+ template.partnership_id == ExportLocation.global_partnership_id
+ end
+ end
+end
diff --git a/lib/abilities/template_conditions.rb b/lib/abilities/template_conditions.rb
index 6fa54ba1..e5490d68 100644
--- a/lib/abilities/template_conditions.rb
+++ b/lib/abilities/template_conditions.rb
@@ -4,10 +4,22 @@ module Abilities
module TemplateConditions
module_function
- def collection(user, ability: nil)
+ def collection(user, ability: nil, request_context: nil)
+ # Handle partnership context first
+ if request_context && request_context[:accessible_partnership_ids].present?
+ return partnership_templates(request_context)
+ end
+
if user.account_id.present?
templates = Template.where(account_id: user.account_id)
+ # Add global partnership templates if configured
+ if ExportLocation.global_partnership_id.present?
+ global_templates = Template.where(partnership_id: ExportLocation.global_partnership_id)
+ template_ids = templates.pluck(:id) + global_templates.pluck(:id)
+ templates = Template.where(id: template_ids.uniq)
+ end
+
return templates unless user.account.testing?
shared_ids =
@@ -15,29 +27,81 @@ module Abilities
.select(:template_id)
Template.where(Template.arel_table[:id].in(Arel::Nodes::Union.new(templates.select(:id).arel, shared_ids.arel)))
- elsif user.account_group_id.present?
- Template.where(account_group_id: user.account_group_id)
else
+ # Partnership users and accounts don't have stored relationships
+ # Authorization happens at controller level via request context
Template.none
end
end
- def entity(template, user:, ability: nil)
- return true if template.account_id.blank? && template.account_group_id.blank?
+ def partnership_templates(request_context)
+ accessible_partnership_ids = request_context[:accessible_partnership_ids] || []
+
+ partnership_ids = Partnership.where(external_partnership_id: accessible_partnership_ids).pluck(:id)
+
+ # Add global partnership if configured
+ partnership_ids << ExportLocation.global_partnership_id if ExportLocation.global_partnership_id.present?
+
+ Template.where(partnership_id: partnership_ids.uniq)
+ end
- # Handle account group templates
- return template.account_group_id == user.account_group_id if template.account_group_id.present?
+ def entity(template, user:, ability: nil, request_context: nil)
+ return true if template.account_id.blank? && template.partnership_id.blank?
+
+ # Check request context first (from API params)
+ # If the template is a partnership template, we need to check the partnership context
+ if request_context && request_context[:accessible_partnership_ids].present?
+ return authorize_via_partnership_context(template, request_context)
+ end
+
+ # Handle partnership templates for account users (no API context)
+ if template.partnership_id.present?
+ return true if global_partnership_template?(template)
+
+ return false
+ end
# Handle regular account templates
+ authorize_account_template(template, user, ability)
+ end
+
+ def authorize_via_partnership_context(template, request_context)
+ accessible_partnership_ids = request_context[:accessible_partnership_ids] || []
+
+ # Handle partnership templates - check if user has access to the partnership
+ if template.partnership_id.present?
+ return true if global_partnership_template?(template)
+
+ partnership = Partnership.find_by(id: template.partnership_id)
+ return false unless partnership
+
+ return accessible_partnership_ids.include?(partnership.external_partnership_id.to_i)
+ end
+
+ # Handle account templates - check if user has access via partnership context
+ if template.account_id.present?
+ return accessible_partnership_ids.any? && request_context[:external_account_id].present?
+ end
+
+ false
+ end
+
+ def authorize_account_template(template, user, ability)
return true if template.account_id == user.account_id
return false unless user.account&.linked_account_account
return false if template.template_sharings.to_a.blank?
account_ids = [user.account_id, TemplateSharing::ALL_ID]
- template.template_sharings.to_a.any? do |e|
- e.account_id.in?(account_ids) && (ability.nil? || e.ability == 'manage' || e.ability == ability)
+ template.template_sharings.to_a.any? do |sharing|
+ sharing.account_id.in?(account_ids) &&
+ (ability.nil? || sharing.ability == 'manage' || sharing.ability == ability)
end
end
+
+ def global_partnership_template?(template)
+ ExportLocation.global_partnership_id.present? &&
+ template.partnership_id == ExportLocation.global_partnership_id
+ end
end
end
diff --git a/lib/ability.rb b/lib/ability.rb
index a721e089..3a75bd42 100644
--- a/lib/ability.rb
+++ b/lib/ability.rb
@@ -3,9 +3,15 @@
class Ability
include CanCan::Ability
- def initialize(user)
- can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |template|
- Abilities::TemplateConditions.entity(template, user:, ability: 'manage')
+ def initialize(user, request_context = nil)
+ can %i[read create update], Template,
+ Abilities::TemplateConditions.collection(user, request_context: request_context) do |template|
+ Abilities::TemplateConditions.entity(
+ template,
+ user: user,
+ ability: 'manage',
+ request_context: request_context
+ )
end
can :destroy, Template, account_id: user.account_id
diff --git a/lib/search_entries.rb b/lib/search_entries.rb
index 5281dbf3..65fc4d1c 100644
--- a/lib/search_entries.rb
+++ b/lib/search_entries.rb
@@ -180,6 +180,11 @@ module SearchEntries
end
def index_template(template)
+ # Skip search indexing for partnership templates since they don't belong to accounts
+ # We currently don't utilize search, so this can be implemented later for partnerships
+ # if that changes.
+ return if template.account_id.blank?
+
sql = SearchEntry.sanitize_sql_array(
["SELECT to_tsvector(:text), to_tsvector('simple', :text)",
{ text: TextUtils.transliterate(template.name.to_s.downcase).delete("\0") }]
diff --git a/lib/submitters/form_configs.rb b/lib/submitters/form_configs.rb
index 9c1fa06f..cb7efaac 100644
--- a/lib/submitters/form_configs.rb
+++ b/lib/submitters/form_configs.rb
@@ -18,7 +18,9 @@ module Submitters
module_function
def call(submitter, keys = [])
- configs = submitter.submission.account.account_configs.where(key: DEFAULT_KEYS + keys)
+ # Allow empty config keys for partnership users
+ account = submitter.submission.account
+ configs = account&.account_configs&.where(key: DEFAULT_KEYS + keys) || []
completed_button = find_safe_value(configs, AccountConfig::FORM_COMPLETED_BUTTON_KEY) || {}
completed_message = find_safe_value(configs, AccountConfig::FORM_COMPLETED_MESSAGE_KEY) || {}
diff --git a/lib/template_folders.rb b/lib/template_folders.rb
index 9a3738ea..0e07c0e7 100644
--- a/lib/template_folders.rb
+++ b/lib/template_folders.rb
@@ -9,9 +9,17 @@ module TemplateFolders
folders.where(TemplateFolder.arel_table[:name].lower.matches("%#{keyword.downcase}%"))
end
- def find_or_create_by_name(author, name)
- return author.account.default_template_folder if name.blank? || name == TemplateFolder::DEFAULT_NAME
+ def find_or_create_by_name(author, name, partnership: nil)
+ return default_folder(author, partnership) if name.blank? || name == TemplateFolder::DEFAULT_NAME
- author.account.template_folders.create_with(author:, account: author.account).find_or_create_by(name:)
+ if partnership.present?
+ partnership.template_folders.create_with(author:, partnership:).find_or_create_by(name:)
+ else
+ author.account.template_folders.create_with(author:, account: author.account).find_or_create_by(name:)
+ end
+ end
+
+ def default_folder(author, partnership)
+ partnership.present? ? partnership.default_template_folder(author) : author.account.default_template_folder
end
end
diff --git a/lib/templates/clone.rb b/lib/templates/clone.rb
index e4b5fc60..73d1f8de 100644
--- a/lib/templates/clone.rb
+++ b/lib/templates/clone.rb
@@ -5,19 +5,31 @@ module Templates
module_function
# rubocop:disable Metrics, Style/CombinableLoops
- def call(original_template, author:, external_id: nil, name: nil, folder_name: nil)
- template = original_template.account.templates.new
+ def call(original_template, author:, external_id: nil, name: nil, folder_name: nil,
+ target_account: nil, target_partnership: nil)
+ # Determine the target for the cloned template
+ template = Template.new
+
+ if target_account.present?
+ template.assign_attributes(account: target_account, partnership: nil)
+ elsif target_partnership.present?
+ template.assign_attributes(partnership: target_partnership, account: nil)
+ else
+ raise ArgumentError, 'Either target_account or target_partnership must be provided'
+ end
template.external_id = external_id
template.shared_link = original_template.shared_link
template.author = author
template.name = name.presence || "#{original_template.name} (#{I18n.t('clone')})"
- if folder_name.present?
- template.folder = TemplateFolders.find_or_create_by_name(author, folder_name)
- else
- template.folder_id = original_template.folder_id
- end
+ template.folder = determine_template_folder(
+ original_template,
+ target_account,
+ target_partnership,
+ author,
+ folder_name
+ )
template.submitters, template.fields, template.schema, template.preferences =
update_submitters_and_fields_and_schema(original_template.submitters.deep_dup,
@@ -99,6 +111,41 @@ module Templates
[cloned_submitters, cloned_fields, cloned_schema, cloned_preferences]
end
+
+ def determine_template_folder(original_template, target_account, target_partnership, author, folder_name)
+ if folder_name.present?
+ create_named_folder(author, folder_name, target_partnership)
+ elsif cloning_between_account_and_partnership?(original_template, target_account, target_partnership)
+ create_default_folder_for_target(target_account, target_partnership, author)
+ else
+ return nil if original_template.folder_id.blank?
+
+ original_template.folder || original_template.folder_id
+ end
+ end
+
+ def create_named_folder(author, folder_name, target_partnership)
+ if target_partnership.present?
+ TemplateFolders.find_or_create_by_name(author, folder_name, partnership: target_partnership)
+ else
+ TemplateFolders.find_or_create_by_name(author, folder_name)
+ end
+ end
+
+ def cloning_between_account_and_partnership?(original_template, target_account, target_partnership)
+ # When cloning across entity types (partnership → account or account → partnership),
+ # we need to create default folders since folder structures don't transfer
+ (target_account.present? && original_template.partnership.present?) ||
+ (target_partnership.present? && original_template.account.present?)
+ end
+
+ def create_default_folder_for_target(target_account, target_partnership, author)
+ if target_partnership.present?
+ target_partnership.default_template_folder(author)
+ else
+ target_account.default_template_folder
+ end
+ end
# rubocop:enable Metrics, Style/CombinableLoops
end
end
diff --git a/lib/templates/clone_to_account.rb b/lib/templates/clone_to_account.rb
new file mode 100644
index 00000000..0f53435d
--- /dev/null
+++ b/lib/templates/clone_to_account.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module Templates
+ module CloneToAccount
+ module_function
+
+ # Clone a partnership template to a specific account
+ # Supports both direct target_account and external_account_id with authorization
+ def call(original_template, author:, target_account: nil, external_account_id: nil, current_user: nil,
+ external_id: nil, name: nil, folder_name: nil)
+ validation_result = validate_inputs(original_template, target_account, external_account_id, current_user)
+ raise validation_result[:error_class], validation_result[:message] if validation_result[:error]
+
+ resolved_target_account = validation_result[:target_account]
+
+ template = Templates::Clone.call(
+ original_template,
+ author: author,
+ external_id: external_id,
+ name: name,
+ folder_name: folder_name,
+ target_account: resolved_target_account
+ )
+
+ # Clear template_accesses since partnership templates shouldn't copy user accesses
+ template.template_accesses.clear
+
+ template
+ end
+
+ def validate_inputs(original_template, target_account, external_account_id, current_user)
+ # Check template type
+ if original_template.partnership_id.blank?
+ return { error: true, error_class: ArgumentError, message: 'Template must be a partnership template' }
+ end
+
+ # Resolve target account
+ if target_account.present?
+ { error: false, target_account: target_account }
+ elsif external_account_id.present?
+ unless current_user
+ return { error: true, error_class: ArgumentError,
+ message: 'current_user required when using external_account_id' }
+ end
+
+ account = Account.find_by(external_account_id: external_account_id)
+ return { error: true, error_class: ActiveRecord::RecordNotFound, message: 'Account not found' } unless account
+
+ unless current_user.account_id == account.id
+ return { error: true, error_class: ArgumentError, message: 'Unauthorized access to target account' }
+ end
+
+ { error: false, target_account: account }
+ else
+ {
+ error: true,
+ error_class: ArgumentError,
+ message: 'Either target_account or external_account_id must be provided'
+ }
+ end
+ end
+ end
+end
diff --git a/lib/templates/clone_to_partnership.rb b/lib/templates/clone_to_partnership.rb
new file mode 100644
index 00000000..376b3d2b
--- /dev/null
+++ b/lib/templates/clone_to_partnership.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Templates
+ module CloneToPartnership
+ module_function
+
+ # Clone a global partnership template to a specific partnership
+ # Supports both direct target_partnership and external_partnership_id with authorization
+ def call(original_template, author:, target_partnership: nil, external_partnership_id: nil, current_user: nil,
+ external_id: nil, name: nil, folder_name: nil)
+ validation_result = validate_inputs(original_template, target_partnership, external_partnership_id, current_user)
+ raise validation_result[:error_class], validation_result[:message] if validation_result[:error]
+
+ resolved_target_partnership = validation_result[:target_partnership]
+
+ template = Templates::Clone.call(
+ original_template,
+ author: author,
+ external_id: external_id,
+ name: name,
+ folder_name: folder_name,
+ target_partnership: resolved_target_partnership
+ )
+
+ # Clear template_accesses since global partnership templates shouldn't copy user accesses
+ template.template_accesses.clear
+
+ template
+ end
+
+ def validate_inputs(original_template, target_partnership, external_partnership_id, current_user)
+ # Check template type - must be global partnership template
+ unless original_template.partnership_id.present? &&
+ ExportLocation.global_partnership_id.present? &&
+ original_template.partnership_id == ExportLocation.global_partnership_id
+ return { error: true, error_class: ArgumentError, message: 'Template must be a global partnership template' }
+ end
+
+ # Resolve target partnership
+ if target_partnership.present?
+ { error: false, target_partnership: target_partnership }
+ elsif external_partnership_id.present?
+ unless current_user
+ return { error: true, error_class: ArgumentError,
+ message: 'current_user required when using external_partnership_id' }
+ end
+
+ partnership = Partnership.find_by(external_partnership_id: external_partnership_id)
+ unless partnership
+ return {
+ error: true,
+ error_class: ActiveRecord::RecordNotFound,
+ message: 'Partnership not found'
+ }
+ end
+
+ # For partnership cloning, we need to verify via API context since users don't have stored relationships
+ # This is a simplified check - in practice, you'd verify via request context
+ { error: false, target_partnership: partnership }
+ else
+ {
+ error: true,
+ error_class: ArgumentError,
+ message: 'Either target_partnership or external_partnership_id must be provided'
+ }
+ end
+ end
+ end
+end
diff --git a/spec/controllers/concerns/partnership_context_spec.rb b/spec/controllers/concerns/partnership_context_spec.rb
new file mode 100644
index 00000000..37d1b9e5
--- /dev/null
+++ b/spec/controllers/concerns/partnership_context_spec.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+describe PartnershipContext do
+ # Create a test class that includes the concern
+ let(:test_class) do
+ Class.new do
+ include PartnershipContext
+
+ attr_accessor :params, :current_user
+
+ def initialize(params = {}, user = nil)
+ @params = params
+ @current_user = user
+ end
+ end
+ end
+
+ let(:test_instance) { test_class.new(params) }
+
+ describe '#partnership_request_context' do
+ context 'when no partnership parameters are provided' do
+ let(:params) { {} }
+
+ it 'returns nil' do
+ expect(test_instance.send(:partnership_request_context)).to be_nil
+ end
+ end
+
+ context 'when accessible_partnership_ids is blank' do
+ let(:params) { { accessible_partnership_ids: [] } }
+
+ it 'returns nil' do
+ expect(test_instance.send(:partnership_request_context)).to be_nil
+ end
+ end
+
+ context 'when accessible_partnership_ids is nil' do
+ let(:params) { { accessible_partnership_ids: nil } }
+
+ it 'returns nil' do
+ expect(test_instance.send(:partnership_request_context)).to be_nil
+ end
+ end
+
+ context 'when partnership parameters are provided' do
+ let(:params) do
+ {
+ accessible_partnership_ids: %w[123 456],
+ external_account_id: 'ext-account-123',
+ external_partnership_id: 'ext-partnership-456'
+ }
+ end
+
+ it 'returns formatted partnership context' do
+ expected_context = {
+ accessible_partnership_ids: [123, 456],
+ external_account_id: 'ext-account-123',
+ external_partnership_id: 'ext-partnership-456'
+ }
+
+ expect(test_instance.send(:partnership_request_context)).to eq(expected_context)
+ end
+
+ it 'converts accessible_partnership_ids to integers' do
+ result = test_instance.send(:partnership_request_context)
+ expect(result[:accessible_partnership_ids]).to eq([123, 456])
+ expect(result[:accessible_partnership_ids]).to all(be_an(Integer))
+ end
+ end
+
+ context 'when only some parameters are provided' do
+ let(:params) do
+ {
+ accessible_partnership_ids: ['123'],
+ external_account_id: 'ext-account-123'
+ }
+ end
+
+ it 'includes only provided parameters' do
+ expected_context = {
+ accessible_partnership_ids: [123],
+ external_account_id: 'ext-account-123',
+ external_partnership_id: nil
+ }
+
+ expect(test_instance.send(:partnership_request_context)).to eq(expected_context)
+ end
+ end
+
+ context 'with string numbers' do
+ let(:params) { { accessible_partnership_ids: %w[123 456] } }
+
+ it 'converts string numbers to integers' do
+ result = test_instance.send(:partnership_request_context)
+ expect(result[:accessible_partnership_ids]).to eq([123, 456])
+ end
+ end
+ end
+
+ describe '#current_ability' do
+ let(:user) { create(:user) }
+ let(:partnership_context) do
+ {
+ accessible_partnership_ids: [123],
+ external_account_id: 'ext-account-123',
+ external_partnership_id: 'ext-partnership-456'
+ }
+ end
+ let(:test_instance) { test_class.new({}, user) }
+
+ before do
+ allow(test_instance).to receive(:partnership_request_context).and_return(partnership_context)
+ end
+
+ it 'creates ability with partnership context' do
+ allow(Ability).to receive(:new).and_call_original
+ test_instance.send(:current_ability)
+ expect(Ability).to have_received(:new).with(user, partnership_context)
+ end
+
+ it 'memoizes the ability instance' do
+ allow(Ability).to receive(:new).and_call_original
+
+ test_instance.send(:current_ability)
+ test_instance.send(:current_ability) # Should use cached version
+
+ expect(Ability).to have_received(:new).once
+ end
+ end
+end
diff --git a/spec/factories/account_groups.rb b/spec/factories/account_groups.rb
deleted file mode 100644
index 4c34d847..00000000
--- a/spec/factories/account_groups.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :account_group do
- external_account_group_id { Faker::Number.unique.number(digits: 8) }
- name { Faker::Company.name }
- end
-end
diff --git a/spec/factories/partnerships.rb b/spec/factories/partnerships.rb
new file mode 100644
index 00000000..3c58ce6d
--- /dev/null
+++ b/spec/factories/partnerships.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :partnership do
+ external_partnership_id { Faker::Number.unique.number(digits: 8) }
+ name { Faker::Company.name }
+ end
+end
diff --git a/spec/factories/submissions.rb b/spec/factories/submissions.rb
index ea59021e..57547fb8 100644
--- a/spec/factories/submissions.rb
+++ b/spec/factories/submissions.rb
@@ -6,7 +6,8 @@ FactoryBot.define do
created_by_user factory: %i[user]
before(:create) do |submission, _|
- submission.account_id = submission.template.account_id
+ # Set account_id from template if not already set
+ submission.account_id ||= submission.template.account_id
submission.template_fields = submission.template.fields
submission.template_schema = submission.template.schema
submission.template_submitters = submission.template.submitters
diff --git a/spec/factories/templates.rb b/spec/factories/templates.rb
index 428500c7..d7b568b4 100644
--- a/spec/factories/templates.rb
+++ b/spec/factories/templates.rb
@@ -357,5 +357,23 @@ FactoryBot.define do
create(:template_access, template:, user: ev.private_access_user || template.author)
end
end
+
+ trait :partnership_template do
+ account { nil }
+
+ transient do
+ partnership { nil }
+ end
+
+ partnership_id do |template|
+ if template.partnership
+ template.partnership.id
+ else
+ create(:partnership).id
+ end
+ end
+
+ author { create(:user, :with_partnership) }
+ end
end
end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index 007f8a50..f8d0af52 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -8,5 +8,9 @@ FactoryBot.define do
password { 'password' }
role { User::ADMIN_ROLE }
email { Faker::Internet.email }
+
+ trait :with_partnership do
+ account { nil }
+ end
end
end
diff --git a/spec/lib/abilities/submission_conditions_spec.rb b/spec/lib/abilities/submission_conditions_spec.rb
new file mode 100644
index 00000000..2dd392a3
--- /dev/null
+++ b/spec/lib/abilities/submission_conditions_spec.rb
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+
+describe Abilities::SubmissionConditions do
+ describe '.collection' do
+ context 'when user has no account_id' do
+ let(:user) { build(:user, account_id: nil) }
+
+ it 'returns empty array' do
+ result = described_class.collection(user)
+ expect(result).to eq([])
+ end
+ end
+
+ context 'when user has account_id' do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+
+ it 'returns submissions for the user account' do
+ # Create submissions for this account
+ template = create(:template, account: account, author: user)
+ submission1 = create(:submission, template: template)
+ submission2 = create(:submission, template: template)
+
+ # Create submission for different account (should not be included)
+ other_account = create(:account)
+ other_user = create(:user, account: other_account)
+ other_template = create(:template, account: other_account, author: other_user)
+ create(:submission, template: other_template)
+
+ result = described_class.collection(user)
+ expect(result).to include(submission1, submission2)
+ expect(result.count).to eq(2)
+ end
+
+ context 'with global partnership templates' do
+ let(:partnership) { create(:partnership) }
+
+ before do
+ allow(ExportLocation).to receive(:global_partnership_id).and_return(partnership.id)
+ end
+
+ it 'includes submissions from global partnership templates' do
+ # Create account submission
+ account_template = create(:template, account: account, author: user)
+ account_submission = create(:submission, template: account_template)
+
+ # Create global partnership submission
+ partnership_template = create(:template, :partnership_template, partnership: partnership)
+ partnership_submission = create(:submission, template: partnership_template, account: account)
+
+ result = described_class.collection(user)
+ expect(result).to include(account_submission, partnership_submission)
+ end
+ end
+
+ context 'with partnership context' do
+ let(:partnership) { create(:partnership, external_partnership_id: 123) }
+
+ it 'includes submissions from accessible partnership templates' do
+ # Create account submission
+ account_template = create(:template, account: account, author: user)
+ account_submission = create(:submission, template: account_template)
+
+ # Create partnership submission
+ partnership_template = create(:template, :partnership_template, partnership: partnership)
+ partnership_submission = create(:submission, template: partnership_template, account: account)
+
+ request_context = { accessible_partnership_ids: [123] }
+ result = described_class.collection(user, request_context: request_context)
+
+ expect(result).to include(account_submission, partnership_submission)
+ end
+ end
+ end
+ end
+
+ describe '.entity' do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+
+ context 'with account submission' do
+ let(:template) { create(:template, account: account, author: user) }
+ let(:submission) { create(:submission, template: template) }
+
+ it 'allows access for account owner' do
+ result = described_class.entity(submission, user: user)
+ expect(result).to be true
+ end
+ end
+
+ context 'with different account submission' do
+ let(:other_account) { create(:account) }
+ let(:other_user) { create(:user, account: other_account) }
+ let(:template) { create(:template, account: other_account, author: other_user) }
+ let(:submission) { create(:submission, template: template) }
+
+ it 'denies access for different account user' do
+ result = described_class.entity(submission, user: user)
+ expect(result).to be false
+ end
+ end
+
+ context 'with global partnership submission' do
+ let(:partnership) { create(:partnership) }
+ let(:template) { create(:template, :partnership_template, partnership: partnership) }
+ let(:other_account) { create(:account) }
+ let(:submission) { create(:submission, template: template, account: other_account) }
+
+ context 'when global partnership' do
+ before do
+ allow(ExportLocation).to receive(:global_partnership_id).and_return(partnership.id)
+ end
+
+ it 'allows access to global partnership submissions' do
+ result = described_class.entity(submission, user: user)
+ expect(result).to be true
+ end
+ end
+
+ context 'when not global partnership' do
+ before do
+ allow(ExportLocation).to receive(:global_partnership_id).and_return(nil)
+ end
+
+ it 'denies access to non-global partnership submissions' do
+ result = described_class.entity(submission, user: user)
+ expect(result).to be false
+ end
+ end
+ end
+
+ context 'with partnership context submission' do
+ let(:partnership) { create(:partnership, external_partnership_id: 456) }
+ let(:template) { create(:template, :partnership_template, partnership: partnership) }
+ let(:other_account) { create(:account) }
+ let(:submission) { create(:submission, template: template, account: other_account) }
+
+ it 'allows access via partnership context' do
+ request_context = { accessible_partnership_ids: [456] }
+ result = described_class.entity(submission, user: user, request_context: request_context)
+ expect(result).to be true
+ end
+
+ it 'denies access without partnership context' do
+ result = described_class.entity(submission, user: user)
+ expect(result).to be false
+ end
+
+ it 'handles integer comparison in partnership context' do
+ partnership = create(:partnership, external_partnership_id: 789)
+ template = create(:template, :partnership_template, partnership: partnership)
+ submission = create(:submission, template: template, account: other_account)
+
+ # accessible_partnership_ids are converted to integers by PartnershipContext concern
+ request_context = { accessible_partnership_ids: [789] }
+ result = described_class.entity(submission, user: user, request_context: request_context)
+ expect(result).to be true
+ end
+ end
+
+ context 'with user without account' do
+ let(:template_author) { create(:user, account: account) }
+ let(:user) { build(:user, account_id: nil) }
+ let(:template) { create(:template, account: account, author: template_author) }
+ let(:submission) { create(:submission, template: template) }
+
+ it 'denies access' do
+ result = described_class.entity(submission, user: user)
+ expect(result).to be false
+ end
+ end
+ end
+end
diff --git a/spec/lib/abilities/template_conditions_spec.rb b/spec/lib/abilities/template_conditions_spec.rb
new file mode 100644
index 00000000..2533c098
--- /dev/null
+++ b/spec/lib/abilities/template_conditions_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+describe Abilities::TemplateConditions do
+ describe '.entity' do
+ context 'when using partnership templates' do
+ let(:partnership) { build(:partnership, id: 1, external_partnership_id: 'test-123') }
+ let(:template) { build(:template, partnership_id: 1, account_id: nil) }
+
+ it 'denies access for users without access tokens' do
+ user = build(:user, account_id: nil)
+ allow(user).to receive(:access_token).and_return(nil)
+ allow(ExportLocation).to receive(:global_partnership_id).and_return(nil)
+
+ result = described_class.entity(template, user: user)
+ expect(result).to be false
+ end
+
+ it 'allows access via partnership context' do
+ partnership = create(:partnership)
+ template = build(:template, partnership: partnership, account_id: nil)
+ user = build(:user, account_id: nil)
+ allow(ExportLocation).to receive(:global_partnership_id).and_return(nil)
+
+ request_context = { accessible_partnership_ids: [partnership.external_partnership_id] }
+ result = described_class.entity(template, user: user, request_context: request_context)
+
+ expect(result).to be true
+ end
+
+ it 'handles integer comparison in partnership context' do
+ partnership = create(:partnership, external_partnership_id: 123)
+ template = build(:template, partnership: partnership, account_id: nil)
+ user = build(:user, account_id: nil)
+ allow(ExportLocation).to receive(:global_partnership_id).and_return(nil)
+
+ # accessible_partnership_ids are converted to integers by PartnershipContext concern
+ request_context = { accessible_partnership_ids: [123] }
+ result = described_class.entity(template, user: user, request_context: request_context)
+ expect(result).to be true
+ end
+
+ it 'allows global partnership templates' do
+ user = build(:user, account_id: 1)
+ allow(ExportLocation).to receive(:global_partnership_id).and_return(1)
+
+ result = described_class.entity(template, user: user)
+ expect(result).to be true
+ end
+ end
+
+ context 'when using account templates' do
+ let(:template) { build(:template, account_id: 1, partnership_id: nil) }
+
+ it 'allows access for account owners' do
+ user = build(:user, account_id: 1)
+ result = described_class.entity(template, user: user)
+ expect(result).to be true
+ end
+
+ it 'denies access for different account users' do
+ user = build(:user, account_id: 2)
+ account = instance_double(Account, linked_account_account: nil)
+ allow(user).to receive(:account).and_return(account)
+
+ result = described_class.entity(template, user: user)
+ expect(result).to be false
+ end
+
+ it 'allows access via partnership context with external_account_id' do
+ user = build(:user, account_id: nil)
+ request_context = {
+ accessible_partnership_ids: ['test-123'],
+ external_account_id: 'ext-123'
+ }
+
+ result = described_class.entity(template, user: user, request_context: request_context)
+ expect(result).to be true
+ end
+ end
+
+ it 'allows unowned templates' do
+ template = build(:template, account_id: nil, partnership_id: nil)
+ user = build(:user)
+
+ result = described_class.entity(template, user: user)
+ expect(result).to be true
+ end
+ end
+end
diff --git a/spec/lib/search_entries_spec.rb b/spec/lib/search_entries_spec.rb
new file mode 100644
index 00000000..21b27ea0
--- /dev/null
+++ b/spec/lib/search_entries_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe SearchEntries do
+ describe '.index_template' do
+ context 'with partnership template' do
+ let(:partnership) { create(:partnership) }
+ let(:template) do
+ create(:template, :partnership_template, partnership: partnership, name: 'Partnership Template')
+ end
+
+ it 'skips search indexing for partnership templates' do
+ result = described_class.index_template(template)
+
+ expect(result).to be_nil
+ expect(template.reload.search_entry).to be_nil
+ end
+
+ it 'does not raise error when account_id is blank' do
+ expect { described_class.index_template(template) }.not_to raise_error
+ end
+
+ it 'logs the reason for skipping partnership templates' do
+ # Verify the early return works as expected
+ expect(template.account_id).to be_nil
+ expect(template.partnership_id).to be_present
+
+ result = described_class.index_template(template)
+ expect(result).to be_nil
+ end
+ end
+
+ context 'with account template' do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account: account) }
+ let(:template) { create(:template, account: account, author: user, name: 'Test Template') }
+
+ it 'processes account templates normally' do
+ expect(template.account_id).to be_present
+ expect(template.partnership_id).to be_nil
+
+ expect { described_class.index_template(template) }.not_to raise_error(ArgumentError, /account_id.blank?/)
+ end
+ end
+ end
+end
diff --git a/spec/lib/templates/clone_spec.rb b/spec/lib/templates/clone_spec.rb
new file mode 100644
index 00000000..c813547f
--- /dev/null
+++ b/spec/lib/templates/clone_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+describe Templates::Clone do
+ describe '.call' do
+ let(:author) { build(:user, id: 1) }
+ let(:original_template) do
+ build(
+ :template,
+ id: 1,
+ name: 'Original',
+ submitters: [],
+ fields: [],
+ schema: [],
+ preferences: {}
+ )
+ end
+
+ it 'requires either target_account or target_partnership' do
+ expect do
+ described_class.call(original_template, author: author)
+ end.to raise_error(ArgumentError, 'Either target_account or target_partnership must be provided')
+ end
+
+ it 'creates template with target_account' do
+ target_account = build(:account, id: 2)
+
+ result = described_class.call(original_template, author: author, target_account: target_account)
+
+ expect(result).to be_a(Template)
+ expect(result.account).to eq(target_account)
+ expect(result.partnership).to be_nil
+ expect(result.author).to eq(author)
+ end
+
+ it 'creates template with target_partnership' do
+ target_partnership = create(:partnership)
+
+ result = described_class.call(original_template, author: author, target_partnership: target_partnership)
+
+ expect(result).to be_a(Template)
+ expect(result.partnership).to eq(target_partnership)
+ expect(result.account).to be_nil
+ expect(result.author).to eq(author)
+ end
+
+ it 'sets custom name when provided' do
+ target_account = build(:account, id: 2)
+
+ result = described_class.call(
+ original_template,
+ author: author,
+ target_account: target_account,
+ name: 'Custom Name'
+ )
+
+ expect(result.name).to eq('Custom Name')
+ end
+
+ it 'generates default clone name when no name provided' do
+ target_account = build(:account, id: 2)
+ allow(I18n).to receive(:t).with('clone').and_return('Clone')
+
+ result = described_class.call(original_template, author: author, target_account: target_account)
+
+ expect(result.name).to eq('Original (Clone)')
+ end
+ end
+end
diff --git a/spec/lib/templates/clone_to_account_spec.rb b/spec/lib/templates/clone_to_account_spec.rb
new file mode 100644
index 00000000..a7c45cda
--- /dev/null
+++ b/spec/lib/templates/clone_to_account_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+describe Templates::CloneToAccount do
+ let(:author) { build(:user, id: 1) }
+
+ describe '.call' do
+ context 'with partnership template' do
+ let(:partnership_template) { build(:template, id: 1, partnership_id: 2, account_id: nil) }
+ let(:target_account) { build(:account, id: 3) }
+
+ it 'clones partnership template to account' do
+ allow(Templates::Clone).to receive(:call).and_return(build(:template))
+
+ result = described_class.call(partnership_template, author: author, target_account: target_account)
+
+ expect(Templates::Clone).to have_received(:call).with(
+ partnership_template,
+ author: author,
+ external_id: nil,
+ name: nil,
+ folder_name: nil,
+ target_account: target_account
+ )
+ expect(result.template_accesses).to be_empty
+ end
+
+ it 'validates partnership template requirement' do
+ account_template = build(:template, partnership_id: nil, account_id: 1)
+
+ expect do
+ described_class.call(account_template, author: author, target_account: target_account)
+ end.to raise_error(ArgumentError, 'Template must be a partnership template')
+ end
+ end
+
+ context 'with external_account_id' do
+ let(:partnership_template) { build(:template, partnership_id: 2, account_id: nil) }
+ let(:current_user) { build(:user, account_id: 3) }
+ let(:target_account) { build(:account, id: 3, external_account_id: 'ext-123') }
+
+ it 'finds account by external_account_id' do
+ allow(Account).to receive(:find_by).with(external_account_id: 'ext-123').and_return(target_account)
+ allow(Templates::Clone).to receive(:call).and_return(build(:template))
+
+ described_class.call(partnership_template,
+ author: author,
+ external_account_id: 'ext-123',
+ current_user: current_user)
+
+ expect(Account).to have_received(:find_by).with(external_account_id: 'ext-123')
+ end
+
+ it 'validates user authorization' do
+ other_user = build(:user, account_id: 999)
+ allow(Account).to receive(:find_by).and_return(target_account)
+
+ expect do
+ described_class.call(partnership_template,
+ author: author,
+ external_account_id: 'ext-123',
+ current_user: other_user)
+ end.to raise_error(ArgumentError, 'Unauthorized access to target account')
+ end
+ end
+ end
+end
diff --git a/spec/lib/templates/clone_to_partnership_spec.rb b/spec/lib/templates/clone_to_partnership_spec.rb
new file mode 100644
index 00000000..f1433d37
--- /dev/null
+++ b/spec/lib/templates/clone_to_partnership_spec.rb
@@ -0,0 +1,171 @@
+# frozen_string_literal: true
+
+describe Templates::CloneToPartnership do
+ let(:account) { create(:account) }
+ let(:partnership) { create(:partnership) }
+ let(:global_partnership) { create(:partnership) }
+ let(:user) { create(:user, account: account) }
+
+ before do
+ allow(ExportLocation).to receive(:global_partnership_id).and_return(global_partnership.id)
+ end
+
+ describe '.call' do
+ context 'with global partnership template' do
+ let(:template) do
+ create(:template, :partnership_template, partnership: global_partnership, name: 'Original Template')
+ end
+
+ it 'clones template to partnership' do
+ result = described_class.call(template, author: user, target_partnership: partnership)
+
+ expect(result).to be_a(Template)
+ expect(result.partnership_id).to eq(partnership.id)
+ expect(result.account_id).to be_nil
+ expect(result.name).to eq('Original Template (Clone)')
+ expect(result.id).not_to eq(template.id)
+ end
+
+ it 'copies template attributes' do
+ template.update!(
+ preferences: { 'test' => 'value' },
+ external_data_fields: { 'field' => 'data' }
+ )
+
+ result = described_class.call(template, author: user, target_partnership: partnership)
+
+ expect(result.preferences).to eq(template.preferences)
+ expect(result.external_data_fields).to eq({})
+ end
+
+ it 'copies submitters' do
+ # Add a submitter to the template's submitters array
+ submitter_uuid = SecureRandom.uuid
+ template.submitters = [{
+ 'uuid' => submitter_uuid,
+ 'name' => 'Test Submitter'
+ }]
+ template.save!
+
+ result = described_class.call(template, author: user, target_partnership: partnership)
+
+ expect(result.submitters.count).to eq(1)
+ expect(result.submitters.first['name']).to eq('Test Submitter')
+ expect(result.submitters.first['uuid']).not_to eq(submitter_uuid)
+ end
+
+ it 'copies fields' do
+ # Add a field to the template's fields array
+ field_uuid = SecureRandom.uuid
+ template.fields = [{
+ 'uuid' => field_uuid,
+ 'name' => 'Test Field',
+ 'type' => 'text',
+ 'required' => true
+ }]
+ template.save!
+
+ result = described_class.call(template, author: user, target_partnership: partnership)
+
+ expect(result.fields.count).to eq(1)
+ expect(result.fields.first['name']).to eq('Test Field')
+ expect(result.fields.first['uuid']).not_to eq(field_uuid)
+ end
+ end
+
+ context 'with partnership template' do
+ let(:template) do
+ create(:template, :partnership_template, partnership: global_partnership, name: 'Partnership Template')
+ end
+
+ it 'clones template to different partnership' do
+ result = described_class.call(template, author: user, target_partnership: partnership)
+
+ expect(result.partnership_id).to eq(partnership.id)
+ expect(result.partnership_id).not_to eq(global_partnership.id)
+ expect(result.account_id).to be_nil
+ expect(result.name).to eq('Partnership Template (Clone)')
+ end
+ end
+
+ context 'with external_id' do
+ let(:template) do
+ create(:template, :partnership_template, partnership: global_partnership, name: 'Global Template')
+ end
+
+ it 'sets external_id when provided' do
+ result = described_class.call(template, author: user, target_partnership: partnership,
+ external_id: 'custom-123')
+
+ expect(result.external_id).to eq('custom-123')
+ end
+
+ it 'does not set external_id when not provided' do
+ result = described_class.call(template, author: user, target_partnership: partnership)
+
+ expect(result.external_id).to be_nil
+ end
+ end
+
+ context 'with author' do
+ let(:template) do
+ create(:template, :partnership_template, partnership: global_partnership, name: 'Global Template')
+ end
+
+ it 'sets author when provided' do
+ result = described_class.call(template, author: user, target_partnership: partnership)
+
+ expect(result.author).to eq(user)
+ end
+
+ it 'uses provided author' do
+ original_author = create(:user, :with_partnership)
+ template.update!(author: original_author)
+
+ result = described_class.call(template, author: user, target_partnership: partnership)
+
+ expect(result.author).to eq(user)
+ end
+ end
+
+ context 'when handling errors' do
+ let(:template) do
+ create(:template, :partnership_template, partnership: global_partnership, name: 'Global Template')
+ end
+
+ it 'raises error if partnership is nil' do
+ expect do
+ described_class.call(template, author: user, target_partnership: nil)
+ end.to raise_error(ArgumentError)
+ end
+ end
+
+ context 'with complex template structure' do
+ let(:template) do
+ create(:template, :partnership_template, partnership: global_partnership, name: 'Global Template')
+ end
+
+ before do
+ # Create a complex template with multiple submitters and fields
+ template.submitters = [
+ { 'uuid' => SecureRandom.uuid, 'name' => 'Submitter 1' },
+ { 'uuid' => SecureRandom.uuid, 'name' => 'Submitter 2' }
+ ]
+ template.fields = [
+ { 'uuid' => SecureRandom.uuid, 'name' => 'Field 1', 'type' => 'text' },
+ { 'uuid' => SecureRandom.uuid, 'name' => 'Field 2', 'type' => 'signature' }
+ ]
+ template.save!
+ end
+
+ it 'clones all components correctly' do
+ result = described_class.call(template, author: user, target_partnership: partnership)
+
+ expect(result.submitters.count).to eq(2)
+ expect(result.fields.count).to eq(2)
+ expect(result.submitters.pluck('name')).to contain_exactly('Submitter 1', 'Submitter 2')
+ expect(result.fields.pluck('name')).to contain_exactly('Field 1', 'Field 2')
+ end
+ end
+ end
+end
diff --git a/spec/models/account_group_spec.rb b/spec/models/account_group_spec.rb
deleted file mode 100644
index d2c02f8e..00000000
--- a/spec/models/account_group_spec.rb
+++ /dev/null
@@ -1,56 +0,0 @@
-# frozen_string_literal: true
-
-# == Schema Information
-#
-# Table name: account_groups
-#
-# id :bigint not null, primary key
-# name :string not null
-# created_at :datetime not null
-# updated_at :datetime not null
-# external_account_group_id :string not null
-#
-# Indexes
-#
-# index_account_groups_on_external_account_group_id (external_account_group_id) UNIQUE
-#
-describe AccountGroup do
- let(:account_group) { create(:account_group) }
-
- describe 'associations' do
- it 'has many accounts' do
- expect(account_group).to respond_to(:accounts)
- end
- end
-
- describe 'validations' do
- it 'validates presence of external_account_group_id' do
- account_group = build(:account_group, external_account_group_id: nil)
- expect(account_group).not_to be_valid
- expect(account_group.errors[:external_account_group_id]).to include("can't be blank")
- end
-
- it 'validates uniqueness of external_account_group_id' do
- create(:account_group, external_account_group_id: 123)
- duplicate = build(:account_group, external_account_group_id: 123)
- expect(duplicate).not_to be_valid
- expect(duplicate.errors[:external_account_group_id]).to include('has already been taken')
- end
-
- it 'validates presence of name' do
- account_group = build(:account_group, name: nil)
- expect(account_group).not_to be_valid
- expect(account_group.errors[:name]).to include("can't be blank")
- end
- end
-
- describe 'when account group is destroyed' do
- it 'nullifies accounts account_group_id' do
- account = create(:account, account_group: account_group)
-
- account_group.destroy
-
- expect(account.reload.account_group).to be_nil
- end
- end
-end
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index f9986e52..20f0296a 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -12,19 +12,13 @@
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
-# account_group_id :bigint
# external_account_id :integer
#
# Indexes
#
-# index_accounts_on_account_group_id (account_group_id)
# index_accounts_on_external_account_id (external_account_id) UNIQUE
# index_accounts_on_uuid (uuid) UNIQUE
#
-# Foreign Keys
-#
-# fk_rails_... (account_group_id => account_groups.id)
-#
require 'rails_helper'
RSpec.describe Account do
@@ -43,16 +37,16 @@ RSpec.describe Account do
describe '.find_or_create_by_external_id' do
let(:external_id) { 123 }
- let(:attributes) { { name: 'Test Account' } }
+ let(:name) { 'Test Account' }
it 'finds existing account by external_account_id' do
existing_account = create(:account, external_account_id: external_id)
- result = described_class.find_or_create_by_external_id(external_id, attributes)
+ result = described_class.find_or_create_by_external_id(external_id, name)
expect(result).to eq(existing_account)
end
it 'creates new account when none exists' do
- result = described_class.find_or_create_by_external_id(external_id, attributes)
+ result = described_class.find_or_create_by_external_id(external_id, name)
expect(result.external_account_id).to eq(external_id)
expect(result.name).to eq('Test Account')
end
@@ -69,17 +63,6 @@ RSpec.describe Account do
end
end
- describe 'account_group association' do
- it 'belongs to account_group optionally' do
- account = create(:account)
- expect(account.account_group).to be_nil
-
- account_group = create(:account_group)
- account.update!(account_group: account_group)
- expect(account.reload.account_group).to eq(account_group)
- end
- end
-
describe '#default_template_folder' do
it 'creates default folder when none exists' do
account = create(:account)
diff --git a/spec/models/concerns/account_group_validation_spec.rb b/spec/models/concerns/account_group_validation_spec.rb
deleted file mode 100644
index e0d74e1d..00000000
--- a/spec/models/concerns/account_group_validation_spec.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe AccountGroupValidation do
- # Test with User model since it includes the concern
- describe 'validation' do
- context 'with account only' do
- it 'is valid' do
- user = build(:user, account: create(:account), account_group: nil)
- expect(user).to be_valid
- end
- end
-
- context 'with account_group only' do
- it 'is valid' do
- user = build(:user, account: nil, account_group: create(:account_group))
- expect(user).to be_valid
- end
- end
-
- context 'with neither account nor account_group' do
- it 'is invalid' do
- user = build(:user, account: nil, account_group: nil)
- expect(user).not_to be_valid
- expect(user.errors[:base]).to include('Must belong to either an account or account group')
- end
- end
-
- context 'with both account and account_group' do
- it 'is invalid' do
- user = build(:user, account: create(:account), account_group: create(:account_group))
- expect(user).not_to be_valid
- expect(user.errors[:base]).to include('Cannot belong to both account and account group')
- end
- end
- end
-end
diff --git a/spec/models/concerns/partnership_validation_spec.rb b/spec/models/concerns/partnership_validation_spec.rb
new file mode 100644
index 00000000..4a35cec2
--- /dev/null
+++ b/spec/models/concerns/partnership_validation_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe PartnershipValidation do
+ # Test with User model since it includes the concern
+ describe 'validation' do
+ context 'with account only' do
+ it 'is valid' do
+ user = build(:user, account: create(:account))
+ expect(user).to be_valid
+ end
+ end
+ end
+end
diff --git a/spec/models/partnership_spec.rb b/spec/models/partnership_spec.rb
new file mode 100644
index 00000000..00bfa3f3
--- /dev/null
+++ b/spec/models/partnership_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: partnerships
+#
+# id :bigint not null, primary key
+# name :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# external_partnership_id :integer not null
+#
+# Indexes
+#
+# index_partnerships_on_external_partnership_id (external_partnership_id) UNIQUE
+#
+describe Partnership do
+ let(:partnership) { create(:partnership) }
+
+ describe 'validations' do
+ it 'validates presence of external_partnership_id' do
+ partnership = build(:partnership, external_partnership_id: nil)
+ expect(partnership).not_to be_valid
+ expect(partnership.errors[:external_partnership_id]).to include("can't be blank")
+ end
+
+ it 'validates uniqueness of external_partnership_id' do
+ create(:partnership, external_partnership_id: 123)
+ duplicate = build(:partnership, external_partnership_id: 123)
+ expect(duplicate).not_to be_valid
+ expect(duplicate.errors[:external_partnership_id]).to include('has already been taken')
+ end
+
+ it 'validates presence of name' do
+ partnership = build(:partnership, name: nil)
+ expect(partnership).not_to be_valid
+ expect(partnership.errors[:name]).to include("can't be blank")
+ end
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 2fe25f43..cf856950 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -28,13 +28,11 @@
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
-# account_group_id :bigint
# account_id :integer
# external_user_id :integer
#
# Indexes
#
-# index_users_on_account_group_id (account_group_id)
# index_users_on_account_id (account_id)
# index_users_on_email (email) UNIQUE
# index_users_on_external_user_id (external_user_id) UNIQUE
@@ -44,7 +42,6 @@
#
# Foreign Keys
#
-# fk_rails_... (account_group_id => account_groups.id)
# fk_rails_... (account_id => accounts.id)
#
require 'rails_helper'
@@ -143,26 +140,4 @@ RSpec.describe User do
expect(user.friendly_name).to eq('john@example.com')
end
end
-
- describe '.find_or_create_by_external_group_id' do
- let(:account_group) { create(:account_group) }
- let(:attributes) { { email: 'test@example.com', first_name: 'John' } }
-
- it 'finds existing user by external_user_id and account_group' do
- existing_user = create(:user, account: nil, account_group: account_group, external_user_id: 123)
-
- result = described_class.find_or_create_by_external_group_id(account_group, 123, attributes)
-
- expect(result).to eq(existing_user)
- end
-
- it 'creates new user when not found' do
- result = described_class.find_or_create_by_external_group_id(account_group, 456, attributes)
-
- expect(result.account_group).to eq(account_group)
- expect(result.external_user_id).to eq(456)
- expect(result.email).to eq('test@example.com')
- expect(result.password).to be_present
- end
- end
end
diff --git a/spec/requests/external_auth_spec.rb b/spec/requests/external_auth_spec.rb
index 34a8e5eb..8bd4bffa 100644
--- a/spec/requests/external_auth_spec.rb
+++ b/spec/requests/external_auth_spec.rb
@@ -32,5 +32,44 @@ describe 'External Auth API' do
expect(response).to have_http_status(:internal_server_error)
expect(response.parsed_body).to eq({ 'error' => 'Internal server error' })
end
+
+ context 'when partnership authentication is used' do
+ let(:partnership_params) do
+ {
+ partnership: { external_id: 'partnership-123', name: 'Test Partnership' },
+ user: { external_id: '456', email: 'test@example.com', first_name: 'John', last_name: 'Doe' }
+ }
+ end
+
+ it 'creates user without account for pure partnership auth' do
+ post '/api/external_auth/user_token', params: partnership_params, as: :json
+
+ expect(response).to have_http_status(:ok)
+ expect(response.parsed_body).to have_key('access_token')
+
+ user = User.find_by(external_user_id: 456)
+ expect(user.account_id).to be_nil
+ end
+
+ it 'creates user with account for hybrid partnership+account auth' do
+ account = create(:account, external_account_id: 789)
+ hybrid_params = partnership_params.merge(external_account_id: 789)
+
+ post '/api/external_auth/user_token', params: hybrid_params, as: :json
+
+ expect(response).to have_http_status(:ok)
+
+ user = User.find_by(external_user_id: 456)
+ expect(user.account_id).to eq(account.id)
+ end
+
+ it 'returns error when account not found' do
+ hybrid_params = partnership_params.merge(external_account_id: 999)
+
+ post '/api/external_auth/user_token', params: hybrid_params, as: :json
+
+ expect(response).to have_http_status(:internal_server_error)
+ end
+ end
end
end
diff --git a/spec/requests/templates_spec.rb b/spec/requests/templates_spec.rb
index a6df983e..7d7f03b1 100644
--- a/spec/requests/templates_spec.rb
+++ b/spec/requests/templates_spec.rb
@@ -173,6 +173,41 @@ describe 'Templates API' do
expect(cloned_template.external_id).to eq('123456')
expect(response.parsed_body).to eq(JSON.parse(clone_template_body(cloned_template).to_json))
end
+
+ context 'when cloning a template' do
+ it 'preserves partnership ownership' do
+ global_partnership = create(:partnership)
+ allow(ExportLocation).to receive(:global_partnership_id).and_return(global_partnership.id)
+
+ partnership_template = create(
+ :template,
+ partnership: global_partnership,
+ account: nil, author: create(:user, account: nil)
+ )
+
+ expect do
+ post "/api/templates/#{partnership_template.id}/clone",
+ headers: { 'x-auth-token': partnership_template.author.access_token.token }
+ end.to change(Template, :count)
+
+ cloned_template = Template.last
+ expect(cloned_template.partnership_id).to eq(partnership_template.partnership_id)
+ expect(cloned_template.account_id).to be_nil
+ end
+
+ it 'preserves account ownership' do
+ account_template = create(:template, account: account, author: author)
+
+ expect do
+ post "/api/templates/#{account_template.id}/clone",
+ headers: { 'x-auth-token': author.access_token.token }
+ end.to change(Template, :count)
+
+ cloned_template = Template.last
+ expect(cloned_template.account_id).to eq(account.id)
+ expect(cloned_template.partnership_id).to be_nil
+ end
+ end
end
private
diff --git a/spec/services/external_auth_service_spec.rb b/spec/services/external_auth_service_spec.rb
index b1422177..638fd97b 100644
--- a/spec/services/external_auth_service_spec.rb
+++ b/spec/services/external_auth_service_spec.rb
@@ -16,7 +16,9 @@ RSpec.describe ExternalAuthService do
context 'with account params' do
let(:params) do
{
- account: { external_id: 456, name: 'Test Account' },
+ account: {
+ external_id: '456', name: 'Test Account', locale: 'en-US', timezone: 'UTC', entity_type: 'Account'
+ },
user: user_params
}
end
@@ -39,29 +41,80 @@ RSpec.describe ExternalAuthService do
end
end
- context 'with account_group params' do
+ context 'with partnership params' do
let(:params) do
{
- account_group: { external_id: 789, name: 'Test Group' },
+ partnership: {
+ external_id: '789', name: 'Test Group', locale: 'en-US', timezone: 'UTC', entity_type: 'Partnership'
+ },
user: user_params
}
end
- it 'returns access token for new account_group and user' do
+ it 'returns access token for new partnership and user' do
token = described_class.new(params).authenticate_user
expect(token).to be_present
- expect(AccountGroup.last.external_account_group_id).to eq(789)
+ expect(Partnership.last.external_partnership_id).to eq(789)
expect(User.last.external_user_id).to eq(123)
end
+
+ it 'returns access token for existing partnership and user' do
+ user = create(:user, account: nil, external_user_id: 123)
+
+ token = described_class.new(params).authenticate_user
+
+ expect(token).to eq(user.access_token.token)
+ end
+ end
+
+ context 'with partnership and account params' do
+ let(:params) do
+ {
+ partnership: {
+ external_id: '789', name: 'Test Group', locale: 'en-US', timezone: 'UTC', entity_type: 'Partnership'
+ },
+ external_account_id: '456',
+ user: user_params
+ }
+ end
+
+ it 'creates partnership user with account context' do
+ account = create(:account, external_account_id: 456)
+
+ token = described_class.new(params).authenticate_user
+
+ expect(token).to be_present
+ expect(Partnership.last.external_partnership_id).to eq(789)
+ expect(User.last.external_user_id).to eq(123)
+ expect(User.last.account_id).to eq(account.id)
+ end
+
+ it 'finds existing partnership user with account context' do
+ create(:account, external_account_id: 456)
+ user = create(:user, account: nil, external_user_id: 123)
+
+ token = described_class.new(params).authenticate_user
+
+ expect(token).to eq(user.access_token.token)
+ expect(User.count).to eq(1)
+ end
+
+ it 'handles external_account_id for account-level operations' do
+ account = create(:account, external_account_id: 456)
+ token = described_class.new(params).authenticate_user
+
+ expect(token).to be_present
+ expect(User.last.account_id).to eq(account.id)
+ end
end
context 'with invalid params' do
- it 'raises error when neither account nor account_group provided' do
+ it 'raises error when neither account nor partnership provided' do
params = { user: user_params }
expect { described_class.new(params).authenticate_user }
- .to raise_error(ArgumentError, 'Either account or account_group params must be provided')
+ .to raise_error(ArgumentError, 'Either account or partnership params must be provided')
end
end
end
diff --git a/spec/services/template_service_spec.rb b/spec/services/template_service_spec.rb
index 141ac9c5..dd0af1f4 100644
--- a/spec/services/template_service_spec.rb
+++ b/spec/services/template_service_spec.rb
@@ -4,25 +4,27 @@ require 'rails_helper'
RSpec.describe TemplateService do
describe '#assign_ownership' do
- let(:template) { build(:template, account: nil, account_group: nil) }
+ let(:template) { build(:template, account: nil, partnership: nil) }
let(:params) { { folder_name: 'Custom Folder' } }
- context 'with account_group user' do
- let(:account_group) { create(:account_group) }
- let(:user) { create(:user, account: nil, account_group: account_group) }
+ context 'with partnership user' do
+ let(:partnership) { create(:partnership) }
+ let(:user) { create(:user, account: nil) }
+ let(:params) { { folder_name: 'Custom Folder', external_partnership_id: partnership.external_partnership_id } }
- it 'assigns account_group and default folder' do
+ it 'assigns partnership and creates custom folder' do
service = described_class.new(template, user, params)
service.assign_ownership
- expect(template.account_group).to eq(account_group)
- expect(template.folder).to eq(account_group.default_template_folder)
+ expect(template.partnership).to eq(partnership)
+ expect(template.folder.name).to eq('Custom Folder')
+ expect(template.folder.partnership).to eq(partnership)
end
end
context 'with account user' do
let(:account) { create(:account) }
- let(:user) { create(:user, account: account, account_group: nil) }
+ let(:user) { create(:user, account: account) }
it 'assigns account and finds/creates folder' do
service = described_class.new(template, user, params)
@@ -33,15 +35,15 @@ RSpec.describe TemplateService do
end
end
- context 'with user having neither account nor account_group' do
- let(:user) { build(:user, account: nil, account_group: nil) }
+ context 'with user having neither account nor partnership' do
+ let(:user) { build(:user, account: nil) }
it 'does not assign ownership' do
service = described_class.new(template, user, params)
service.assign_ownership
expect(template.account).to be_nil
- expect(template.account_group).to be_nil
+ expect(template.partnership).to be_nil
end
end
end
diff --git a/spec/services/token_refresh_service_spec.rb b/spec/services/token_refresh_service_spec.rb
new file mode 100644
index 00000000..cf30d5ee
--- /dev/null
+++ b/spec/services/token_refresh_service_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe TokenRefreshService do
+ describe '#refresh_token' do
+ let(:user_params) do
+ {
+ user: {
+ external_id: 123,
+ email: 'test@example.com',
+ first_name: 'John',
+ last_name: 'Doe'
+ }
+ }
+ end
+
+ context 'when user exists' do
+ let!(:user) { create(:user, external_user_id: 123) }
+
+ it 'destroys existing token and creates new one' do
+ original_token = user.access_token.token
+ original_token_id = user.access_token.id
+
+ new_token = described_class.new(user_params).refresh_token
+
+ expect(new_token).to be_present
+ expect(new_token).not_to eq(original_token)
+
+ # Verify the original access token was actually destroyed
+ expect(AccessToken.find_by(id: original_token_id)).to be_nil
+
+ # Verify user has a new access token
+ user.reload
+ expect(user.access_token).to be_present
+ expect(user.access_token.token).to eq(new_token)
+ expect(user.access_token.id).not_to eq(original_token_id)
+ end
+
+ it 'handles user without existing access token' do
+ user.access_token.destroy
+
+ new_token = described_class.new(user_params).refresh_token
+
+ expect(new_token).to be_present
+ expect(user.reload.access_token).to be_present
+ end
+ end
+
+ context 'when user does not exist' do
+ it 'returns nil' do
+ result = described_class.new(user_params).refresh_token
+
+ expect(result).to be_nil
+ end
+ end
+
+ context 'with invalid params' do
+ it 'returns nil when external_id is missing' do
+ invalid_params = { user: { email: 'test@example.com' } }
+
+ result = described_class.new(invalid_params).refresh_token
+
+ expect(result).to be_nil
+ end
+ end
+ end
+end