From 7d592d476138661f4ab2b62b026e2269a07f51f0 Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Tue, 30 Sep 2025 16:08:40 -0500 Subject: [PATCH 1/5] CP-11138 partnership refactor (#22) * account group to partnership rename * this is mostly converting the name account_group => partnership * partnership user relationships are API request based * so we don't need to maintain relational information in two databases, this many to many relationship is now handled via API context * rubocop and test fixes --- app/controllers/templates_controller.rb | 2 +- app/models/account.rb | 11 +--- app/models/account_group.rb | 36 ------------ .../concerns/account_group_validation.rb | 19 ------- app/models/concerns/partnership_validation.rb | 19 +++++++ app/models/export_location.rb | 22 ++++---- app/models/partnership.rb | 36 ++++++++++++ app/models/template.rb | 14 ++--- app/models/template_folder.rb | 28 +++++----- app/models/user.rb | 18 ------ app/services/external_auth_service.rb | 47 +++++++++++----- app/services/template_service.rb | 15 ++++- ...0_rename_account_groups_to_partnerships.rb | 18 ++++++ db/schema.rb | 37 ++++++------ lib/abilities/template_conditions.rb | 51 ++++++++++++++--- lib/template_folders.rb | 14 ++++- spec/factories/account_groups.rb | 8 --- spec/factories/partnerships.rb | 8 +++ spec/models/account_group_spec.rb | 56 ------------------- spec/models/account_spec.rb | 23 +------- .../concerns/account_group_validation_spec.rb | 38 ------------- .../concerns/partnership_validation_spec.rb | 15 +++++ spec/models/partnership_spec.rb | 40 +++++++++++++ spec/models/user_spec.rb | 25 --------- spec/services/external_auth_service_spec.rb | 18 +++--- spec/services/template_service_spec.rb | 24 ++++---- 26 files changed, 314 insertions(+), 328 deletions(-) delete mode 100644 app/models/account_group.rb delete mode 100644 app/models/concerns/account_group_validation.rb create mode 100644 app/models/concerns/partnership_validation.rb create mode 100644 app/models/partnership.rb create mode 100644 db/migrate/20250924174100_rename_account_groups_to_partnerships.rb delete mode 100644 spec/factories/account_groups.rb create mode 100644 spec/factories/partnerships.rb delete mode 100644 spec/models/account_group_spec.rb delete mode 100644 spec/models/concerns/account_group_validation_spec.rb create mode 100644 spec/models/concerns/partnership_validation_spec.rb create mode 100644 spec/models/partnership_spec.rb diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index f8908f27..c000263d 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -164,7 +164,7 @@ class TemplatesController < ApplicationController return unless authorized_clone_account_id?(params[:account_id]) @template.account_id = params[:account_id] - @template.account_group = nil + @template.partnership = nil @template.folder = @template.account.default_template_folder if @template.account_id != current_account&.id end diff --git a/app/models/account.rb b/app/models/account.rb index fc9803b5..d7d3c96d 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -12,23 +12,16 @@ # 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) -# class Account < ApplicationRecord attribute :uuid, :string, default: -> { SecureRandom.uuid } - belongs_to :account_group, optional: true has_many :users, dependent: :destroy has_many :encrypted_configs, dependent: :destroy has_many :account_configs, dependent: :destroy @@ -66,9 +59,9 @@ class Account < ApplicationRecord scope :active, -> { where(archived_at: nil) } - def self.find_or_create_by_external_id(external_id, attributes = {}) + def self.find_or_create_by_external_id(external_id, name, attributes = {}) find_by(external_account_id: external_id) || - create!(attributes.merge(external_account_id: external_id)) + create!(attributes.merge(external_account_id: external_id, name: name)) end def testing? diff --git a/app/models/account_group.rb b/app/models/account_group.rb deleted file mode 100644 index e2bde3f0..00000000 --- a/app/models/account_group.rb +++ /dev/null @@ -1,36 +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 :integer not null -# -# Indexes -# -# index_account_groups_on_external_account_group_id (external_account_group_id) UNIQUE -# -class AccountGroup < ApplicationRecord - has_many :accounts, dependent: :nullify - has_many :users, dependent: :nullify - has_many :templates, dependent: :destroy - has_many :template_folders, dependent: :destroy - - validates :external_account_group_id, presence: true, uniqueness: true - validates :name, presence: true - - def self.find_or_create_by_external_id(external_id, attributes = {}) - find_by(external_account_group_id: external_id) || - create!(attributes.merge(external_account_group_id: external_id)) - end - - def default_template_folder - template_folders.find_by(name: TemplateFolder::DEFAULT_NAME) || - template_folders.create!(name: TemplateFolder::DEFAULT_NAME, - author_id: users.minimum(:id)) - end -end diff --git a/app/models/concerns/account_group_validation.rb b/app/models/concerns/account_group_validation.rb deleted file mode 100644 index 028e03f8..00000000 --- a/app/models/concerns/account_group_validation.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module AccountGroupValidation - extend ActiveSupport::Concern - - included do - validate :must_belong_to_account_or_account_group - end - - private - - def must_belong_to_account_or_account_group - if account.blank? && account_group.blank? - errors.add(:base, 'Must belong to either an account or account group') - elsif account.present? && account_group.present? - errors.add(:base, 'Cannot belong to both account and account group') - end - end -end diff --git a/app/models/concerns/partnership_validation.rb b/app/models/concerns/partnership_validation.rb new file mode 100644 index 00000000..915d957d --- /dev/null +++ b/app/models/concerns/partnership_validation.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PartnershipValidation + extend ActiveSupport::Concern + + included do + validate :must_belong_to_account_or_partnership + end + + private + + def must_belong_to_account_or_partnership + if account.blank? && partnership.blank? + errors.add(:base, 'Must belong to either an account or partnership') + elsif account.present? && partnership.present? + errors.add(:base, 'Cannot belong to both account and partnership') + end + end +end diff --git a/app/models/export_location.rb b/app/models/export_location.rb index 4090ef84..cf0c364d 100644 --- a/app/models/export_location.rb +++ b/app/models/export_location.rb @@ -4,17 +4,19 @@ # # Table name: export_locations # -# id :bigint not null, primary key -# api_base_url :string not null -# authorization_token :string -# default_location :boolean default(FALSE), not null -# extra_params :jsonb not null -# name :string not null -# submissions_endpoint :string -# templates_endpoint :string -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint not null, primary key +# api_base_url :string not null +# authorization_token :string +# default_location :boolean default(FALSE), not null +# extra_params :jsonb not null +# name :string not null +# submissions_endpoint :string +# templates_endpoint :string +# created_at :datetime not null +# updated_at :datetime not null +# global_partnership_id :integer # +# global_partnership_id is the Docuseal partnership ID associated with the export location class ExportLocation < ApplicationRecord validates :name, presence: true validates :api_base_url, presence: true diff --git a/app/models/partnership.rb b/app/models/partnership.rb new file mode 100644 index 00000000..c0591395 --- /dev/null +++ b/app/models/partnership.rb @@ -0,0 +1,36 @@ +# 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 +# +class Partnership < ApplicationRecord + has_many :templates, dependent: :destroy + has_many :template_folders, dependent: :destroy + + validates :external_partnership_id, presence: true, uniqueness: true + validates :name, presence: true + + def self.find_or_create_by_external_id(external_id, name, attributes = {}) + find_by(external_partnership_id: external_id) || + create!(attributes.merge(external_partnership_id: external_id, name: name)) + end + + def default_template_folder(author) + raise ArgumentError, 'Author is required for partnership template folders' unless author + + template_folders.find_by(name: TemplateFolder::DEFAULT_NAME) || + template_folders.create!(name: TemplateFolder::DEFAULT_NAME, + author: author) + end +end diff --git a/app/models/template.rb b/app/models/template.rb index 34cffdba..e7399124 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -17,38 +17,38 @@ # submitters :text not null # created_at :datetime not null # updated_at :datetime not null -# account_group_id :bigint # account_id :integer # author_id :integer not null # external_id :string # folder_id :integer not null +# partnership_id :bigint # # Indexes # -# index_templates_on_account_group_id (account_group_id) # index_templates_on_account_id (account_id) # index_templates_on_account_id_and_folder_id_and_id (account_id,folder_id,id) WHERE (archived_at IS NULL) # index_templates_on_account_id_and_id_archived (account_id,id) WHERE (archived_at IS NOT NULL) # index_templates_on_author_id (author_id) # index_templates_on_external_id (external_id) # index_templates_on_folder_id (folder_id) +# index_templates_on_partnership_id (partnership_id) # index_templates_on_slug (slug) UNIQUE # # Foreign Keys # -# fk_rails_... (account_group_id => account_groups.id) # fk_rails_... (account_id => accounts.id) # fk_rails_... (author_id => users.id) # fk_rails_... (folder_id => template_folders.id) +# fk_rails_... (partnership_id => partnerships.id) # class Template < ApplicationRecord - include AccountGroupValidation + include PartnershipValidation DEFAULT_SUBMITTER_NAME = 'Employee' belongs_to :author, class_name: 'User' belongs_to :account, optional: true - belongs_to :account_group, optional: true + belongs_to :partnership, optional: true belongs_to :folder, class_name: 'TemplateFolder' has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy @@ -92,8 +92,8 @@ class Template < ApplicationRecord def maybe_set_default_folder if account.present? self.folder ||= account.default_template_folder - elsif account_group.present? - self.folder ||= account_group.default_template_folder + elsif partnership.present? + self.folder ||= partnership.default_template_folder(author) end end end diff --git a/app/models/template_folder.rb b/app/models/template_folder.rb index c06ffeed..601940ac 100644 --- a/app/models/template_folder.rb +++ b/app/models/template_folder.rb @@ -4,35 +4,35 @@ # # Table name: template_folders # -# id :bigint not null, primary key -# archived_at :datetime -# name :string not null -# created_at :datetime not null -# updated_at :datetime not null -# account_group_id :bigint -# account_id :integer -# author_id :integer not null +# id :bigint not null, primary key +# archived_at :datetime +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer +# author_id :integer not null +# partnership_id :bigint # # Indexes # -# index_template_folders_on_account_group_id (account_group_id) -# index_template_folders_on_account_id (account_id) -# index_template_folders_on_author_id (author_id) +# index_template_folders_on_account_id (account_id) +# index_template_folders_on_author_id (author_id) +# index_template_folders_on_partnership_id (partnership_id) # # Foreign Keys # -# fk_rails_... (account_group_id => account_groups.id) # fk_rails_... (account_id => accounts.id) # fk_rails_... (author_id => users.id) +# fk_rails_... (partnership_id => partnerships.id) # class TemplateFolder < ApplicationRecord - include AccountGroupValidation + include PartnershipValidation DEFAULT_NAME = 'Default' belongs_to :author, class_name: 'User' belongs_to :account, optional: true - belongs_to :account_group, optional: true + belongs_to :partnership, optional: true has_many :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder has_many :active_templates, -> { where(archived_at: nil) }, diff --git a/app/models/user.rb b/app/models/user.rb index 0649504b..01f0b24f 100644 --- a/app/models/user.rb +++ b/app/models/user.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,12 +42,9 @@ # # Foreign Keys # -# fk_rails_... (account_group_id => account_groups.id) # fk_rails_... (account_id => accounts.id) # class User < ApplicationRecord - include AccountGroupValidation - ROLES = [ ADMIN_ROLE = 'admin' ].freeze @@ -63,7 +58,6 @@ class User < ApplicationRecord has_one_attached :initials belongs_to :account, optional: true - belongs_to :account_group, optional: true has_one :access_token, dependent: :destroy has_many :access_tokens, dependent: :destroy @@ -95,23 +89,11 @@ class User < ApplicationRecord ) end - def self.find_or_create_by_external_group_id(account_group, external_id, attributes = {}) - account_group.users.find_by(external_user_id: external_id) || - account_group.users.create!( - attributes.merge( - external_user_id: external_id, - password: SecureRandom.hex(16) - ) - ) - end - def access_token super || build_access_token.tap(&:save!) end def active_for_authentication? - return false unless account.present? || account_group.present? - super && !archived_at? && !account&.archived_at? end diff --git a/app/services/external_auth_service.rb b/app/services/external_auth_service.rb index 569f85ca..7966b6e6 100644 --- a/app/services/external_auth_service.rb +++ b/app/services/external_auth_service.rb @@ -8,10 +8,10 @@ class ExternalAuthService def authenticate_user user = if @params[:account].present? find_or_create_user_with_account - elsif @params[:account_group].present? - find_or_create_user_with_account_group + elsif @params[:partnership].present? + find_or_create_user_with_partnership else - raise ArgumentError, 'Either account or account_group params must be provided' + raise ArgumentError, 'Either account or partnership params must be provided' end user.access_token.token @@ -22,9 +22,11 @@ class ExternalAuthService def find_or_create_user_with_account account = Account.find_or_create_by_external_id( @params[:account][:external_id]&.to_i, - name: @params[:account][:name], - locale: @params[:account][:locale] || 'en-US', - timezone: @params[:account][:timezone] || 'UTC' + @params[:account][:name], + { + locale: @params[:account][:locale] || 'en-US', + timezone: @params[:account][:timezone] || 'UTC' + } ) User.find_or_create_by_external_id( @@ -34,16 +36,31 @@ class ExternalAuthService ) end - def find_or_create_user_with_account_group - account_group = AccountGroup.find_or_create_by_external_id( - @params[:account_group][:external_id], - name: @params[:account_group][:name] - ) + def find_or_create_user_with_partnership + # Ensure partnerships exist in DocuSeal before creating the user + # We need these partnerships to exist for templates and authorization to work + ensure_partnerships_exist - User.find_or_create_by_external_group_id( - account_group, - @params[:user][:external_id]&.to_i, - user_attributes + # For partnership users, we don't store any partnership relationship + # They get authorized via API request context (accessible_partnership_ids) + # Just ensure the user exists in DocuSeal for authentication + User.find_by(external_user_id: @params[:user][:external_id]&.to_i) || + User.create!( + user_attributes.merge( + external_user_id: @params[:user][:external_id]&.to_i, + password: SecureRandom.hex(16) + # NOTE: No account_id or partnership_id - authorization comes from API context + ) + ) + end + + def ensure_partnerships_exist + # Create the partnership if it doesn't exist in DocuSeal + return if @params[:partnership].blank? + + Partnership.find_or_create_by_external_id( + @params[:partnership][:external_id], + @params[:partnership][:name] ) end diff --git a/app/services/template_service.rb b/app/services/template_service.rb index a91b967b..32faa009 100644 --- a/app/services/template_service.rb +++ b/app/services/template_service.rb @@ -8,9 +8,18 @@ class TemplateService end def assign_ownership - if @user.account_group.present? - @template.account_group = @user.account_group - @template.folder = @user.account_group.default_template_folder + if @params[:external_partnership_id].present? + partnership = Partnership.find_by(external_partnership_id: @params[:external_partnership_id]) + raise ArgumentError, "Partnership not found: #{@params[:external_partnership_id]}" unless partnership + + @template.partnership = partnership + @template.folder = TemplateFolders.find_or_create_by_name(@user, @params[:folder_name], partnership: partnership) + elsif @params[:external_account_id].present? + account = Account.find_by(external_account_id: @params[:external_account_id]) + raise ArgumentError, "Account not found: #{@params[:external_account_id]}" unless account + + @template.account = account + @template.folder = TemplateFolders.find_or_create_by_name(@user, @params[:folder_name]) elsif @user.account.present? @template.account = @user.account @template.folder = TemplateFolders.find_or_create_by_name(@user, @params[:folder_name]) 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..bb43cde6 --- /dev/null +++ b/db/migrate/20250924174100_rename_account_groups_to_partnerships.rb @@ -0,0 +1,18 @@ +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 + rename_column :export_locations, :global_account_group_id, :global_partnership_id + + # 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/template_conditions.rb b/lib/abilities/template_conditions.rb index 6fa54ba1..01d965e8 100644 --- a/lib/abilities/template_conditions.rb +++ b/lib/abilities/template_conditions.rb @@ -4,7 +4,12 @@ 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) @@ -15,18 +20,31 @@ 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) + + Template.where(partnership_id: partnership_ids) + end + + 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 request_context && request_context[:accessible_partnership_ids].present? + return authorize_via_partnership_context(template, request_context) + end - # Handle account group templates - return template.account_group_id == user.account_group_id if template.account_group_id.present? + # Handle partnership templates - users don't have stored relationships anymore + # This should not be reached for partnership users since they use API context # Handle regular account templates return true if template.account_id == user.account_id @@ -39,5 +57,24 @@ module Abilities e.account_id.in?(account_ids) && (ability.nil? || e.ability == 'manage' || e.ability == ability) end 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? + partnership = Partnership.find_by(id: template.partnership_id) + return false unless partnership + + return accessible_partnership_ids.include?(partnership.external_partnership_id) + 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 end end 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/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/models/account_group_spec.rb b/spec/models/account_group_spec.rb deleted file mode 100644 index bc291c26..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 :integer 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/services/external_auth_service_spec.rb b/spec/services/external_auth_service_spec.rb index b1422177..3141d017 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,31 @@ 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 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 From f4dc26786fcf9c7fca0c5d0241f94d36e52143cd Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Tue, 30 Sep 2025 17:20:50 -0500 Subject: [PATCH 2/5] Update 20250924174100_rename_account_groups_to_partnerships.rb (#24) --- .../20250924174100_rename_account_groups_to_partnerships.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/db/migrate/20250924174100_rename_account_groups_to_partnerships.rb b/db/migrate/20250924174100_rename_account_groups_to_partnerships.rb index bb43cde6..9075cec8 100644 --- a/db/migrate/20250924174100_rename_account_groups_to_partnerships.rb +++ b/db/migrate/20250924174100_rename_account_groups_to_partnerships.rb @@ -6,7 +6,9 @@ class RenameAccountGroupsToPartnerships < ActiveRecord::Migration[8.0] # 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 - rename_column :export_locations, :global_account_group_id, :global_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 From 741c548d269e68f5dcdafc0e6d5b010a99d541d7 Mon Sep 17 00:00:00 2001 From: Ryan Arakawa Date: Wed, 22 Oct 2025 16:17:17 -0500 Subject: [PATCH 3/5] 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 @@ /> diff --git a/app/views/submissions/show.html.erb b/app/views/submissions/show.html.erb index 122521b6..308dc3c2 100644 --- a/app/views/submissions/show.html.erb +++ b/app/views/submissions/show.html.erb @@ -4,10 +4,7 @@ <% with_signature_id, is_combined_enabled = AccountConfig.where(account_id: @submission.account_id, key: [AccountConfig::COMBINE_PDF_RESULT_KEY, AccountConfig::WITH_SIGNATURE_ID], value: true).then { |configs| [configs.any? { |e| e.key == AccountConfig::WITH_SIGNATURE_ID }, configs.any? { |e| e.key == AccountConfig::COMBINE_PDF_RESULT_KEY }] } %>
- - <%= render 'submissions/logo' %> - <% (@submission.name || @submission.template.name).split(/(_)/).each do |item| %><%= item %><% end %> - +
<% last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %> <% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %> @@ -230,11 +227,6 @@
<% end %> - <% if signed_in? && submitter && submitter.completed_at? && submitter.email == current_user.email && submitter.completed_at > 1.month.ago && can?(:update, @submission) %> -
- <%= button_to t('resubmit'), submitters_resubmit_path(submitter), method: :put, class: 'btn btn-sm btn-primary w-full', form: { target: '_blank' }, data: { turbo: false } %> -
- <% end %> <% if signed_in? && submitter && submitter.completed_at? && !submitter.declined_at? && !submitter.changes_requested_at? && current_user == @submission.template.author %>
<%= link_to 'Request Changes', request_changes_submitter_path(submitter.slug), diff --git a/app/views/submit_form/_docuseal_logo.html.erb b/app/views/submit_form/_docuseal_logo.html.erb index 3f3ac923..e69de29b 100644 --- a/app/views/submit_form/_docuseal_logo.html.erb +++ b/app/views/submit_form/_docuseal_logo.html.erb @@ -1,3 +0,0 @@ - - <%= Docuseal.product_name %> - diff --git a/app/views/submit_form/completed.html.erb b/app/views/submit_form/completed.html.erb index 8fde2c79..be5ee7cf 100644 --- a/app/views/submit_form/completed.html.erb +++ b/app/views/submit_form/completed.html.erb @@ -16,36 +16,6 @@
- <% if (Docuseal.multitenant? || Accounts.can_send_emails?(@submitter.account)) && @submitter.email.present? %> - - <%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, class: 'white-button w-full' %> - - <% if Templates.filter_undefined_submitters(@submitter.submission.template_submitters).size != 1 %> -
<%= t('or') %>
- <% else %> -
- <% end %> - <% end %> - <% if @submitter.completed_at > 30.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %> - - - <%= svg_icon('download', class: 'w-6 h-6') %> - <%= t('download_documents') %> - - - - <% end %>
- <% undefined_submitters = Templates.filter_undefined_submitters(@submitter.submission.template_submitters) %> - <% if undefined_submitters.size == 1 && undefined_submitters.first['uuid'] == @submitter.uuid && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && @submitter.template && !@submitter.template.archived_at? %> -
<%= t('or') %>
- - <%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), resubmit_form_path, params: { resubmit: @submitter.slug }, method: :put, class: 'white-button w-full' %> - - <% end %>
-<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %> diff --git a/app/views/submit_form/show.html.erb b/app/views/submit_form/show.html.erb index 8ec61a66..c9e7d340 100644 --- a/app/views/submit_form/show.html.erb +++ b/app/views/submit_form/show.html.erb @@ -14,44 +14,7 @@ <%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 transition-transform duration-300 %> <%= local_assigns[:banner_html] || capture do %> <%= render('submit_form/banner') %> -
-
- <%= @submitter.submission.name || @submitter.submission.template.name %> -
-
- <% if @form_configs[:with_decline] %> - <% decline_modal_checkbox_uuid = SecureRandom.uuid %> -
- <%= render 'shared/html_modal', title: t(:decline), btn_text: t(:decline), btn_class: 'btn btn-sm !px-5', button_id: 'decline_button', uuid: decline_modal_checkbox_uuid do %> - <%= render 'submit_form/decline_form', submitter: @submitter %> - <% end %> -
- <% end %> - <% if @form_configs[:with_partial_download] %> - - - <%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %> - - - - - <% end %> -
-