Merge remote-tracking branch 'origin/main' into CP-11289

pull/544/head
Bernardo Anderson 2 months ago
commit 2cfa5533a2

3
.gitignore vendored

@ -38,4 +38,5 @@ yarn-debug.log*
/ee /ee
dump.rdb dump.rdb
.aider* .aider*
.kilocode/* .kilocode/*
CLAUDE.local.md

@ -4,6 +4,7 @@ module Api
class ApiBaseController < ActionController::API class ApiBaseController < ActionController::API
include ActiveStorage::SetCurrent include ActiveStorage::SetCurrent
include Pagy::Backend include Pagy::Backend
include PartnershipContext
DEFAULT_LIMIT = 10 DEFAULT_LIMIT = 10
MAX_LIMIT = 100 MAX_LIMIT = 100
@ -45,11 +46,13 @@ module Api
return 'Not authorized' unless error.subject.respond_to?(:account_id) return 'Not authorized' unless error.subject.respond_to?(:account_id)
linked_account_record_exists = linked_account_record_exists =
if current_user.account.testing? if current_user.account&.testing?
current_user.account.linked_account_accounts.where(account_type: 'testing') current_user.account.linked_account_accounts.where(account_type: 'testing')
.exists?(account_id: error.subject.account_id) .exists?(account_id: error.subject.account_id)
else elsif current_user.account
current_user.account.testing_accounts.exists?(id: error.subject.account_id) current_user.account.testing_accounts.exists?(id: error.subject.account_id)
else
false
end end
return 'Not authorized' unless linked_account_record_exists return 'Not authorized' unless linked_account_record_exists

@ -187,7 +187,7 @@ module Api
def submissions_params def submissions_params
permitted_attrs = [ permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last, :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], message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role, submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,

@ -5,37 +5,91 @@ module Api
load_and_authorize_resource :template load_and_authorize_resource :template
def create 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) 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( ActiveRecord::Associations::Preloader.new(
records: [@template], records: [template],
associations: [schema_documents: :preview_images_attachments] associations: [schema_documents: :preview_images_attachments]
).call ).call
cloned_template = Templates::Clone.call( # Determine target for same-type cloning (clone to same ownership type as original)
@template, 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, author: current_user,
name: params[:name], name: params[:name],
external_id: params[:external_id].presence || params[:application_key], 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.source = :api
cloned_template
end
def finalize_and_render_response(cloned_template)
schema_documents = Templates::CloneAttachments.call(template: cloned_template, schema_documents = Templates::CloneAttachments.call(template: cloned_template,
original_template: @template, original_template: @template,
documents: params[:documents]) documents: params[:documents])
cloned_template.save! cloned_template.save!
WebhookUrls.for_account_id(cloned_template.account_id, 'template.created').each do |webhook_url| enqueue_webhooks(cloned_template)
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id,
'webhook_url_id' => webhook_url.id)
end
SearchEntries.enqueue_reindex(cloned_template) SearchEntries.enqueue_reindex(cloned_template)
render json: Templates::SerializeForApi.call(cloned_template, schema_documents) render json: Templates::SerializeForApi.call(cloned_template, schema_documents)
end 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
end end

@ -70,11 +70,7 @@ module Api
@template.update!(template_params) @template.update!(template_params)
SearchEntries.enqueue_reindex(@template) SearchEntries.enqueue_reindex(@template)
enqueue_template_updated_webhooks
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
render json: @template.as_json(only: %i[id updated_at]) render json: @template.as_json(only: %i[id updated_at])
end end
@ -151,13 +147,30 @@ module Api
def build_template def build_template
template = Template.new template = Template.new
template.account = current_account
template.author = current_user template.author = current_user
template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
template.name = params[:name] || 'Untitled Template' template.name = params[:name] || 'Untitled Template'
template.external_id = params[:external_id] if params[:external_id].present? template.external_id = params[:external_id] if params[:external_id].present?
template.source = :api template.source = :api
template.submitters = params[:submitters] if params[:submitters].present? 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 template
end end
@ -199,9 +212,18 @@ module Api
end end
def enqueue_template_created_webhooks(template) def enqueue_template_created_webhooks(template)
WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url| enqueue_template_webhooks(template, 'template.created', SendTemplateCreatedWebhookRequestJob)
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id, end
'webhook_url_id' => webhook_url.id)
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
end end

@ -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

@ -31,7 +31,9 @@ module IframeAuthentication
return return
end end
Rails.logger.error "#{self.class.name}: Authentication failed" Rails.logger.error "#{self.class.name}: Authentication failed - no token found. " \
"Params: #{params.keys}, Session has token: #{session[:auth_token].present?}, " \
"Referer: #{request.referer}"
render json: { error: 'Authentication required' }, status: :unauthorized render json: { error: 'Authentication required' }, status: :unauthorized
end end
end end

@ -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

@ -2,6 +2,7 @@
class TemplateDocumentsController < ApplicationController class TemplateDocumentsController < ApplicationController
include IframeAuthentication include IframeAuthentication
include PartnershipContext
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
skip_before_action :authenticate_via_token! skip_before_action :authenticate_via_token!

@ -3,6 +3,7 @@
class TemplatesController < ApplicationController class TemplatesController < ApplicationController
include PrefillFieldsHelper include PrefillFieldsHelper
include IframeAuthentication include IframeAuthentication
include PartnershipContext
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
skip_before_action :authenticate_via_token!, only: [:update] skip_before_action :authenticate_via_token!, only: [:update]
@ -51,7 +52,8 @@ class TemplatesController < ApplicationController
methods: %i[metadata signed_uuid], methods: %i[metadata signed_uuid],
include: { preview_images: { methods: %i[url metadata filename] } } 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 ).to_json
render :edit, layout: 'plain' render :edit, layout: 'plain'
@ -64,9 +66,20 @@ class TemplatesController < ApplicationController
associations: [schema_documents: :preview_images_attachments] associations: [schema_documents: :preview_images_attachments]
).call ).call
@template = Templates::Clone.call(@base_template, author: current_user, # Determine target for same-type cloning (clone to same ownership type as original)
name: params.dig(:template, :name), target_args = if @base_template.account_id.present?
folder_name: params[:folder_name]) { 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 else
@template = Template.new(template_params) if @template.nil? @template = Template.new(template_params) if @template.nil?
@template.author = current_user @template.author = current_user
@ -164,7 +177,7 @@ class TemplatesController < ApplicationController
return unless authorized_clone_account_id?(params[:account_id]) return unless authorized_clone_account_id?(params[:account_id])
@template.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 @template.folder = @template.account.default_template_folder if @template.account_id != current_account&.id
end end

@ -1,8 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class TemplatesFormPreviewController < ApplicationController class TemplatesFormPreviewController < ApplicationController
include IframeAuthentication
include PartnershipContext
layout 'form' layout 'form'
skip_before_action :authenticate_via_token!
before_action :authenticate_from_referer
load_and_authorize_resource :template load_and_authorize_resource :template
def show def show

@ -36,6 +36,7 @@ export default class extends HTMLElement {
} }
isHeaderNotVisible () { isHeaderNotVisible () {
if (!this.header) return true
const rect = this.header.getBoundingClientRect() const rect = this.header.getBoundingClientRect()
return rect.bottom <= 0 || rect.top >= window.innerHeight return rect.bottom <= 0 || rect.top >= window.innerHeight
} }

@ -33,36 +33,6 @@
{{ completedButton.title || 'Back to Website' }} {{ completedButton.title || 'Back to Website' }}
</span> </span>
</a> </a>
<button
v-if="canSendEmail && !isDemo && withSendCopyButton"
class="white-button !h-auto flex items-center space-x-1 w-full completed-form-send-copy-button"
:disabled="isSendingCopy"
@click.prevent="sendCopyToEmail"
>
<IconInnerShadowTop
v-if="isSendingCopy"
class="animate-spin"
/>
<IconMail v-else />
<span>
{{ t('send_copy_via_email') }}
</span>
</button>
<button
v-if="!isWebView && withDownloadButton"
class="base-button flex items-center space-x-1 w-full completed-form-download-button"
:disabled="isDownloading"
@click.prevent="download"
>
<IconInnerShadowTop
v-if="isDownloading"
class="animate-spin"
/>
<IconDownload v-else />
<span>
{{ t('download') }}
</span>
</button>
<a <a
v-if="isDemo" v-if="isDemo"
target="_blank" target="_blank"
@ -85,17 +55,6 @@
</span> </span>
</a> </a>
</div> </div>
<div
v-if="attribution"
class="text-center mt-4"
>
{{ t('powered_by') }}
<a
href="https://www.docuseal.com/start"
target="_blank"
class="underline"
>DocuSeal</a> - {{ t('open_source_documents_software') }}
</div>
</div> </div>
</template> </template>

@ -64,7 +64,7 @@
/> />
<template v-else> <template v-else>
<a <a
:href="`/templates/${template.id}/form`" :href="formPreviewUrl"
data-turbo="false" data-turbo="false"
class="primary-button" class="primary-button"
> >
@ -349,7 +349,6 @@ import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, Ic
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { ref, computed, toRaw } from 'vue' import { ref, computed, toRaw } from 'vue'
import * as i18n from './i18n' import * as i18n from './i18n'
export default { export default {
name: 'TemplateBuilder', name: 'TemplateBuilder',
components: { components: {
@ -690,6 +689,26 @@ export default {
return this.template.schema.map((item) => { return this.template.schema.map((item) => {
return this.template.documents.find(doc => doc.uuid === item.attachment_uuid) return this.template.documents.find(doc => doc.uuid === item.attachment_uuid)
}) })
},
formPreviewUrl () {
let url = `/templates/${this.template.id}/form`
if (this.template.partnership_context) {
const params = new URLSearchParams()
const context = this.template.partnership_context
if (context.accessible_partnership_ids) {
context.accessible_partnership_ids.forEach(id => {
params.append('accessible_partnership_ids[]', id)
})
}
if (context.external_partnership_id) {
params.append('external_partnership_id', context.external_partnership_id)
}
if (context.external_account_id) {
params.append('external_account_id', context.external_account_id)
}
url += `?${params.toString()}`
}
return url
} }
}, },
created () { created () {
@ -1448,6 +1467,22 @@ export default {
const formData = new FormData() const formData = new FormData()
formData.append('files[]', file) formData.append('files[]', file)
// Add partnership context if available
if (this.template.partnership_context) {
const context = this.template.partnership_context
if (context.accessible_partnership_ids) {
context.accessible_partnership_ids.forEach(id => {
formData.append('accessible_partnership_ids[]', id)
})
}
if (context.external_partnership_id) {
formData.append('external_partnership_id', context.external_partnership_id)
}
if (context.external_account_id) {
formData.append('external_account_id', context.external_account_id)
}
}
this.baseFetch(`/templates/${this.template.id}/documents`, { this.baseFetch(`/templates/${this.template.id}/documents`, {
method: 'POST', method: 'POST',
@ -1722,7 +1757,26 @@ export default {
this.pushUndo() this.pushUndo()
return this.baseFetch(`/templates/${this.template.id}`, { // Build URL with partnership context if available
let url = `/templates/${this.template.id}`
if (this.template.partnership_context) {
const params = new URLSearchParams()
const context = this.template.partnership_context
if (context.accessible_partnership_ids) {
context.accessible_partnership_ids.forEach(id => {
params.append('accessible_partnership_ids[]', id)
})
}
if (context.external_partnership_id) {
params.append('external_partnership_id', context.external_partnership_id)
}
if (context.external_account_id) {
params.append('external_account_id', context.external_account_id)
}
url += `?${params.toString()}`
}
return this.baseFetch(url, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
template: { template: {

@ -73,7 +73,7 @@ export default {
IconFileSymlink, IconFileSymlink,
IconFiles IconFiles
}, },
inject: ['baseFetch', 't'], inject: ['baseFetch', 't', 'template'],
props: { props: {
templateId: { templateId: {
type: [Number, String], type: [Number, String],

@ -48,7 +48,7 @@ export default {
IconUpload, IconUpload,
IconInnerShadowTop IconInnerShadowTop
}, },
inject: ['baseFetch', 't'], inject: ['baseFetch', 't', 'template'],
props: { props: {
templateId: { templateId: {
type: [Number, String], type: [Number, String],
@ -78,10 +78,28 @@ export default {
async upload () { async upload () {
this.isLoading = true this.isLoading = true
const formData = new FormData(this.$refs.form)
// Add partnership context if available
if (this.template?.partnership_context) {
const context = this.template.partnership_context
if (context.accessible_partnership_ids) {
context.accessible_partnership_ids.forEach(id => {
formData.append('accessible_partnership_ids[]', id)
})
}
if (context.external_partnership_id) {
formData.append('external_partnership_id', context.external_partnership_id)
}
if (context.external_account_id) {
formData.append('external_account_id', context.external_account_id)
}
}
this.baseFetch(this.uploadUrl, { this.baseFetch(this.uploadUrl, {
method: 'POST', method: 'POST',
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
body: new FormData(this.$refs.form) body: formData
}).then((resp) => { }).then((resp) => {
if (resp.ok) { if (resp.ok) {
resp.json().then((data) => { resp.json().then((data) => {
@ -95,6 +113,22 @@ export default {
const formData = new FormData(this.$refs.form) const formData = new FormData(this.$refs.form)
formData.append('password', prompt(this.t('enter_pdf_password'))) formData.append('password', prompt(this.t('enter_pdf_password')))
// Add partnership context if available
if (this.template?.partnership_context) {
const context = this.template.partnership_context
if (context.accessible_partnership_ids) {
context.accessible_partnership_ids.forEach(id => {
formData.append('accessible_partnership_ids[]', id)
})
}
if (context.external_partnership_id) {
formData.append('external_partnership_id', context.external_partnership_id)
}
if (context.external_account_id) {
formData.append('external_account_id', context.external_account_id)
}
}
this.baseFetch(this.uploadUrl, { this.baseFetch(this.uploadUrl, {
method: 'POST', method: 'POST',

@ -12,23 +12,16 @@
# uuid :string not null # uuid :string not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_group_id :bigint
# external_account_id :integer # external_account_id :integer
# #
# Indexes # Indexes
# #
# index_accounts_on_account_group_id (account_group_id)
# index_accounts_on_external_account_id (external_account_id) UNIQUE # index_accounts_on_external_account_id (external_account_id) UNIQUE
# index_accounts_on_uuid (uuid) UNIQUE # index_accounts_on_uuid (uuid) UNIQUE
# #
# Foreign Keys
#
# fk_rails_... (account_group_id => account_groups.id)
#
class Account < ApplicationRecord class Account < ApplicationRecord
attribute :uuid, :string, default: -> { SecureRandom.uuid } attribute :uuid, :string, default: -> { SecureRandom.uuid }
belongs_to :account_group, optional: true
has_many :users, dependent: :destroy has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy has_many :account_configs, dependent: :destroy
@ -66,9 +59,9 @@ class Account < ApplicationRecord
scope :active, -> { where(archived_at: nil) } 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) || 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 end
def testing? def testing?

@ -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 :string 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

@ -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

@ -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

@ -4,17 +4,19 @@
# #
# Table name: export_locations # Table name: export_locations
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# api_base_url :string not null # api_base_url :string not null
# authorization_token :string # authorization_token :string
# default_location :boolean default(FALSE), not null # default_location :boolean default(FALSE), not null
# extra_params :jsonb not null # extra_params :jsonb not null
# name :string not null # name :string not null
# submissions_endpoint :string # submissions_endpoint :string
# templates_endpoint :string # templates_endpoint :string
# created_at :datetime not null # created_at :datetime not null
# updated_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 class ExportLocation < ApplicationRecord
validates :name, presence: true validates :name, presence: true
validates :api_base_url, presence: true validates :api_base_url, presence: true
@ -22,4 +24,8 @@ class ExportLocation < ApplicationRecord
def self.default_location def self.default_location
where(default_location: true).first || ExportLocation.first where(default_location: true).first || ExportLocation.first
end end
def self.global_partnership_id
default_location&.global_partnership_id
end
end end

@ -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

@ -17,38 +17,38 @@
# submitters :text not null # submitters :text not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_group_id :bigint
# account_id :integer # account_id :integer
# author_id :integer not null # author_id :integer not null
# external_id :string # external_id :string
# folder_id :integer not null # folder_id :integer not null
# partnership_id :bigint
# #
# Indexes # Indexes
# #
# index_templates_on_account_group_id (account_group_id)
# index_templates_on_account_id (account_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_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_account_id_and_id_archived (account_id,id) WHERE (archived_at IS NOT NULL)
# index_templates_on_author_id (author_id) # index_templates_on_author_id (author_id)
# index_templates_on_external_id (external_id) # index_templates_on_external_id (external_id)
# index_templates_on_folder_id (folder_id) # index_templates_on_folder_id (folder_id)
# index_templates_on_partnership_id (partnership_id)
# index_templates_on_slug (slug) UNIQUE # index_templates_on_slug (slug) UNIQUE
# #
# Foreign Keys # Foreign Keys
# #
# fk_rails_... (account_group_id => account_groups.id)
# fk_rails_... (account_id => accounts.id) # fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id) # fk_rails_... (author_id => users.id)
# fk_rails_... (folder_id => template_folders.id) # fk_rails_... (folder_id => template_folders.id)
# fk_rails_... (partnership_id => partnerships.id)
# #
class Template < ApplicationRecord class Template < ApplicationRecord
include AccountGroupValidation include PartnershipValidation
DEFAULT_SUBMITTER_NAME = 'Employee' DEFAULT_SUBMITTER_NAME = 'Employee'
belongs_to :author, class_name: 'User' belongs_to :author, class_name: 'User'
belongs_to :account, optional: true belongs_to :account, optional: true
belongs_to :account_group, optional: true belongs_to :partnership, optional: true
belongs_to :folder, class_name: 'TemplateFolder' belongs_to :folder, class_name: 'TemplateFolder'
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy
@ -92,8 +92,8 @@ class Template < ApplicationRecord
def maybe_set_default_folder def maybe_set_default_folder
if account.present? if account.present?
self.folder ||= account.default_template_folder self.folder ||= account.default_template_folder
elsif account_group.present? elsif partnership.present?
self.folder ||= account_group.default_template_folder self.folder ||= partnership.default_template_folder(author)
end end
end end
end end

@ -4,35 +4,35 @@
# #
# Table name: template_folders # Table name: template_folders
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# archived_at :datetime # archived_at :datetime
# name :string not null # name :string not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_group_id :bigint # account_id :integer
# account_id :integer # author_id :integer not null
# author_id :integer not null # partnership_id :bigint
# #
# Indexes # Indexes
# #
# index_template_folders_on_account_group_id (account_group_id) # index_template_folders_on_account_id (account_id)
# index_template_folders_on_account_id (account_id) # index_template_folders_on_author_id (author_id)
# index_template_folders_on_author_id (author_id) # index_template_folders_on_partnership_id (partnership_id)
# #
# Foreign Keys # Foreign Keys
# #
# fk_rails_... (account_group_id => account_groups.id)
# fk_rails_... (account_id => accounts.id) # fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id) # fk_rails_... (author_id => users.id)
# fk_rails_... (partnership_id => partnerships.id)
# #
class TemplateFolder < ApplicationRecord class TemplateFolder < ApplicationRecord
include AccountGroupValidation include PartnershipValidation
DEFAULT_NAME = 'Default' DEFAULT_NAME = 'Default'
belongs_to :author, class_name: 'User' belongs_to :author, class_name: 'User'
belongs_to :account, optional: true 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 :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder
has_many :active_templates, -> { where(archived_at: nil) }, has_many :active_templates, -> { where(archived_at: nil) },

@ -28,13 +28,11 @@
# uuid :string not null # uuid :string not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_group_id :bigint
# account_id :integer # account_id :integer
# external_user_id :integer # external_user_id :integer
# #
# Indexes # Indexes
# #
# index_users_on_account_group_id (account_group_id)
# index_users_on_account_id (account_id) # index_users_on_account_id (account_id)
# index_users_on_email (email) UNIQUE # index_users_on_email (email) UNIQUE
# index_users_on_external_user_id (external_user_id) UNIQUE # index_users_on_external_user_id (external_user_id) UNIQUE
@ -44,12 +42,9 @@
# #
# Foreign Keys # Foreign Keys
# #
# fk_rails_... (account_group_id => account_groups.id)
# fk_rails_... (account_id => accounts.id) # fk_rails_... (account_id => accounts.id)
# #
class User < ApplicationRecord class User < ApplicationRecord
include AccountGroupValidation
ROLES = [ ROLES = [
ADMIN_ROLE = 'admin' ADMIN_ROLE = 'admin'
].freeze ].freeze
@ -63,7 +58,6 @@ class User < ApplicationRecord
has_one_attached :initials has_one_attached :initials
belongs_to :account, optional: true belongs_to :account, optional: true
belongs_to :account_group, optional: true
has_one :access_token, dependent: :destroy has_one :access_token, dependent: :destroy
has_many :access_tokens, dependent: :destroy has_many :access_tokens, dependent: :destroy
@ -95,23 +89,11 @@ class User < ApplicationRecord
) )
end 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 def access_token
super || build_access_token.tap(&:save!) super || build_access_token.tap(&:save!)
end end
def active_for_authentication? def active_for_authentication?
return false unless account.present? || account_group.present?
super && !archived_at? && !account&.archived_at? super && !archived_at? && !account&.archived_at?
end end

@ -8,10 +8,15 @@ class ExternalAuthService
def authenticate_user def authenticate_user
user = if @params[:account].present? user = if @params[:account].present?
find_or_create_user_with_account find_or_create_user_with_account
elsif @params[:account_group].present? elsif @params[:partnership].present?
find_or_create_user_with_account_group # Check if account context is also provided for account-level operations
if @params[:external_account_id].present?
find_or_create_user_with_partnership_and_account
else
find_or_create_user_with_partnership
end
else else
raise ArgumentError, 'Either account or account_group params must be provided' raise ArgumentError, 'Either account or partnership params must be provided'
end end
user.access_token.token user.access_token.token
@ -22,9 +27,11 @@ class ExternalAuthService
def find_or_create_user_with_account def find_or_create_user_with_account
account = Account.find_or_create_by_external_id( account = Account.find_or_create_by_external_id(
@params[:account][:external_id]&.to_i, @params[:account][:external_id]&.to_i,
name: @params[:account][:name], @params[:account][:name],
locale: @params[:account][:locale] || 'en-US', {
timezone: @params[:account][:timezone] || 'UTC' locale: @params[:account][:locale] || 'en-US',
timezone: @params[:account][:timezone] || 'UTC'
}
) )
User.find_or_create_by_external_id( User.find_or_create_by_external_id(
@ -34,17 +41,56 @@ class ExternalAuthService
) )
end end
def find_or_create_user_with_account_group def find_or_create_user_with_partnership
account_group = AccountGroup.find_or_create_by_external_id( # Ensure partnerships exist in DocuSeal before creating the user
@params[:account_group][:external_id], # We need these partnerships to exist for templates and authorization to work
name: @params[:account_group][:name] ensure_partnerships_exist
# For partnership users, we don't store any partnership relationship
# They get authorized via API request context (accessible_partnership_ids)
find_or_create_user_by_external_id(account: nil)
end
def find_or_create_user_with_partnership_and_account
# Hybrid approach: partnership authentication with account context
ensure_partnerships_exist
# Find the target account by external_account_id
account = Account.find_by(external_account_id: @params[:external_account_id])
raise ArgumentError, "Account not found for external_account_id: #{@params[:external_account_id]}" unless account
find_or_create_user_by_external_id(account: account)
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
User.find_or_create_by_external_group_id( def find_or_create_user_by_external_id(account: nil)
account_group, external_user_id = @params[:user][:external_id]&.to_i
@params[:user][:external_id]&.to_i, user = User.find_by(external_user_id: external_user_id)
user_attributes
if user.present?
# If user exists and we have an account context, assign them to the account if they don't have one
user.update!(account: account) if account.present? && user.account_id.blank?
return user
end
# Create new user
create_attributes = user_attributes.merge(
external_user_id: external_user_id,
password: SecureRandom.hex(16)
) )
create_attributes[:account] = account if account.present?
User.create!(create_attributes)
end end
def user_attributes def user_attributes

@ -8,9 +8,18 @@ class TemplateService
end end
def assign_ownership def assign_ownership
if @user.account_group.present? if @params[:external_partnership_id].present?
@template.account_group = @user.account_group partnership = Partnership.find_by(external_partnership_id: @params[:external_partnership_id])
@template.folder = @user.account_group.default_template_folder 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? elsif @user.account.present?
@template.account = @user.account @template.account = @user.account
@template.folder = TemplateFolders.find_or_create_by_name(@user, @params[:folder_name]) @template.folder = TemplateFolders.find_or_create_by_name(@user, @params[:folder_name])

@ -0,0 +1,32 @@
# frozen_string_literal: true
class TokenRefreshService
def initialize(params)
@params = params
end
def refresh_token
user = find_user
return nil unless user
user.access_token&.destroy
user.association(:access_token).reset
user.reload
user.create_access_token!
user.access_token.token
end
private
def find_user
external_user_id = @params.dig(:user, :external_id)&.to_i
return nil unless external_user_id
user = User.find_by(external_user_id: external_user_id)
Rails.logger.warn "Token refresh requested for non-existent user: external_id #{external_user_id}" unless user
user
end
end

@ -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 }] } %> <% 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 }] } %>
<div style="max-width: 1600px" class="mx-auto pl-4"> <div style="max-width: 1600px" class="mx-auto pl-4">
<div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100"> <div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100">
<a href="<%= signed_in? && @submission.account_id == current_account&.id && @submission.template ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1"> <div></div>
<span><%= render 'submissions/logo' %></span>
<span class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><% (@submission.name || @submission.template.name).split(/(_)/).each do |item| %><%= item %><wbr><% end %></span>
</a>
<div class="space-x-3 flex items-center"> <div class="space-x-3 flex items-center">
<% last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %> <% last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %>
<% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %> <% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %>
@ -230,11 +227,6 @@
</a> </a>
</div> </div>
<% end %> <% end %>
<% if signed_in? && submitter && submitter.completed_at? && submitter.email == current_user.email && submitter.completed_at > 1.month.ago && can?(:update, @submission) %>
<div class="mt-2 mb-1">
<%= button_to t('resubmit'), submitters_resubmit_path(submitter), method: :put, class: 'btn btn-sm btn-primary w-full', form: { target: '_blank' }, data: { turbo: false } %>
</div>
<% end %>
<% if signed_in? && submitter && submitter.completed_at? && !submitter.declined_at? && !submitter.changes_requested_at? && current_user == @submission.template.author %> <% if signed_in? && submitter && submitter.completed_at? && !submitter.declined_at? && !submitter.changes_requested_at? && current_user == @submission.template.author %>
<div class="mt-2 mb-1"> <div class="mt-2 mb-1">
<%= link_to 'Request Changes', request_changes_submitter_path(submitter.slug), <%= link_to 'Request Changes', request_changes_submitter_path(submitter.slug),

@ -1,3 +0,0 @@
<a href="<%= root_path %>" class="mx-auto text-2xl md:text-3xl font-bold items-center flex space-x-3">
<span><%= Docuseal.product_name %></span>
</a>

@ -16,36 +16,6 @@
</div> </div>
</div> </div>
<div> <div>
<% if (Docuseal.multitenant? || Accounts.can_send_emails?(@submitter.account)) && @submitter.email.present? %>
<toggle-submit>
<%= 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' %>
</toggle-submit>
<% if Templates.filter_undefined_submitters(@submitter.submission.template_submitters).size != 1 %>
<div class="divider uppercase"><%= t('or') %></div>
<% else %>
<div class="py-2"></div>
<% end %>
<% end %>
<% if @submitter.completed_at > 30.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %>
<download-button data-src="<%= submitter_download_index_path(@submitter.slug) %>" class="base-button w-full">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %>
<span><%= t('download_documents') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span><%= t('downloading') %></span>
</span>
</download-button>
<% end %>
</div> </div>
<% 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? %>
<div class="divider uppercase"><%= t('or') %></div>
<toggle-submit class="block">
<%= 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' %>
</toggle-submit>
<% end %>
</div> </div>
</div> </div>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>

@ -1,5 +1,5 @@
<% content_for(:html_title, "#{@submitter.submission.name || @submitter.submission.template.name} | DocuSeal") %> <% content_for(:html_title, "#{@submitter.submission.name || @submitter.submission.template.name} | DocuSeal") %>
<% content_for(:html_description, "#{@submitter.account.name} has invited you to fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.") %> <% content_for(:html_description, "#{@submitter.account&.name || 'You have been invited'} to fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.") %>
<% template_fields = @submitter.submission.template_fields || @submitter.submission.template.fields %> <% template_fields = @submitter.submission.template_fields || @submitter.submission.template.fields %>
<% fields_index = Templates.build_field_areas_index(template_fields) %> <% fields_index = Templates.build_field_areas_index(template_fields) %>
<% submitter_values = @submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %> <% submitter_values = @submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
@ -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 %> <%# 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 %> <%= local_assigns[:banner_html] || capture do %>
<%= render('submit_form/banner') %> <%= render('submit_form/banner') %>
<div id="signing_form_header" class="sticky min-[1230px]:static top-0 z-50 bg-base-100 py-2 px-2 flex items-center md:-mx-[8px]" style="margin-bottom: -16px">
<div class="text-xl md:text-2xl font-medium focus:text-clip" style="width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<%= @submitter.submission.name || @submitter.submission.template.name %>
</div>
<div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0">
<% if @form_configs[:with_decline] %>
<% decline_modal_checkbox_uuid = SecureRandom.uuid %>
<div>
<%= 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 %>
</div>
<% end %>
<% if @form_configs[:with_partial_download] %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4">
<span class="flex items-center justify-center" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %>
<span class="hidden md:inline"><%= t('download') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span class="hidden md:inline"><%= t('downloading') %></span>
</span>
</download-button>
<% end %>
</div>
</div>
<scroll-buttons class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10"> <scroll-buttons class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10">
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid %>" class="btn btn-sm px-0">
<span class="min-[1366px]:inline hidden px-3">
<%= t(:decline) %>
</span>
<span class="inline min-[1366px]:hidden px-2">
<%= svg_icon('x', class: 'w-5 h-5') %>
</span>
</label>
<% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2"> <download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2">
<span data-target="download-button.defaultButton"> <span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %> <%= svg_icon('download', class: 'w-5 h-5') %>
@ -84,14 +47,25 @@
<% next if field['conditions'].present? && values[field['uuid']].blank? && field['submitter_uuid'] != @submitter.uuid %> <% next if field['conditions'].present? && values[field['uuid']].blank? && field['submitter_uuid'] != @submitter.uuid %>
<% next if field['conditions'].present? && field['submitter_uuid'] == @submitter.uuid %> <% next if field['conditions'].present? && field['submitter_uuid'] == @submitter.uuid %>
<% next if field.dig('preferences', 'formula').present? && field['submitter_uuid'] == @submitter.uuid %> <% next if field.dig('preferences', 'formula').present? && field['submitter_uuid'] == @submitter.uuid %>
<%= render 'submissions/value', area:, field:, attachments_index: @attachments_index, value: field.dig('preferences', 'mask').present? ? TextUtils.mask_value(value, field.dig('preferences', 'mask')) : value, locale: @submitter.account.locale, timezone: @submitter.account.timezone, submitter: submitters_index[field['submitter_uuid']], with_signature_id: @form_configs[:with_signature_id] %> <%= render 'submissions/value',
area:,
field:,
attachments_index: @attachments_index,
value: field.dig('preferences', 'mask').present? ? TextUtils.mask_value(value, field.dig('preferences', 'mask')) : value,
locale: @submitter.account&.locale || 'en',
timezone: @submitter.account&.timezone || 'UTC',
submitter: submitters_index[field['submitter_uuid']],
with_signature_id: @form_configs[:with_signature_id] %>
<% end %> <% end %>
</div> </div>
</div> </div>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account, with_style: false %> <%# Partnership not supported %>
<% if @submitter.account %>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account, with_style: false %>
<% end %>
<% if @form_configs[:policy_links].present? %> <% if @form_configs[:policy_links].present? %>
<div class="text-center md:text-neutral-500 md:pr-3 md:pb-3 md:text-sm md:text-left mt-2 md:mt-0 md:fixed md:bottom-0 md:right-0"> <div class="text-center md:text-neutral-500 md:pr-3 md:pb-3 md:text-sm md:text-left mt-2 md:mt-0 md:fixed md:bottom-0 md:right-0">
<div class="md:max-w-[8rem] flex flex-wrap md:flex-col justify-center md:justify-start md:items-start space-x-2 md:space-x-0"> <div class="md:max-w-[8rem] flex flex-wrap md:flex-col justify-center md:justify-start md:items-start space-x-2 md:space-x-0">

@ -1,11 +1,11 @@
<% banner_html = capture do %> <% banner_html = capture do %>
<div class="sticky top-0 z-50 bg-base-100 py-2 px-2 flex items-center" style="margin: 0px -8px -16px -8px"> <div class="sticky top-0 z-50 bg-base-100 py-2 px-2 flex items-center justify-end" style="margin: 0px -8px -16px -8px">
<div class="text-xl md:text-3xl font-semibold focus:text-clip" style="width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"> <div class="flex items-center" style="flex-shrink: 0">
<%= @template.name %>
</div>
<div class="flex items-center" style="margin-left: 20px; flex-shrink: 0">
<% if @template.submitters.size > 1 %> <% if @template.submitters.size > 1 %>
<form action="<%= template_form_path(@template) %>" method="get" class="mr-3"> <form action="<%= template_form_path(@template) %>" method="get" class="mr-3">
<% if (auth_token = params[:auth_token] || session[:auth_token]) %>
<%= hidden_field_tag :auth_token, auth_token %>
<% end %>
<select onchange="this.form.submit()" name="uuid" class="select base-input text-center font-normal" style="width: 180px; flex-shrink: 0;"> <select onchange="this.form.submit()" name="uuid" class="select base-input text-center font-normal" style="width: 180px; flex-shrink: 0;">
<% @template.submitters.each do |submitter| %> <% @template.submitters.each do |submitter| %>
<%= tag.option(value: submitter['uuid'], selected: submitter['uuid'] == @submitter.uuid) do %> <%= tag.option(value: submitter['uuid'], selected: submitter['uuid'] == @submitter.uuid) do %>
@ -15,7 +15,7 @@
</select> </select>
</form> </form>
<% end %> <% end %>
<a href="<%= edit_template_path(@template) %>" class="base-button" data-turbo="false" style="flex-shrink: 0; padding: 0px 24px;"> <a href="<%= edit_template_path(@template, params[:auth_token] || session[:auth_token] ? { auth_token: params[:auth_token] || session[:auth_token] } : {}) %>" class="base-button" data-turbo="false" style="flex-shrink: 0; padding: 0px 24px;">
<%= t('exit_preview') %> <%= t('exit_preview') %>
</a> </a>
</div> </div>

@ -61,6 +61,12 @@ Rails.application.routes.draw do
post :user_token post :user_token
end end
end end
resources :token_refresh, only: [] do
collection do
post :create
end
end
end end
resources :export, controller: 'export' do resources :export, controller: 'export' do

@ -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

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql" 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" t.index ["account_id"], name: "index_account_configs_on_account_id"
end 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| create_table "account_linked_accounts", force: :cascade do |t|
t.integer "account_id", null: false t.integer "account_id", null: false
t.integer "linked_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.string "uuid", null: false
t.datetime "archived_at" t.datetime "archived_at"
t.integer "external_account_id" 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 ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
t.index ["uuid"], name: "index_accounts_on_uuid", unique: true t.index ["uuid"], name: "index_accounts_on_uuid", unique: true
end end
@ -238,6 +228,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.jsonb "extra_params", default: {}, null: false t.jsonb "extra_params", default: {}, null: false
t.string "submissions_endpoint" t.string "submissions_endpoint"
t.integer "global_partnership_id"
end end
create_table "oauth_access_grants", force: :cascade do |t| 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 t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
end 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| create_table "search_entries", force: :cascade do |t|
t.string "record_type", null: false t.string "record_type", null: false
t.bigint "record_id", 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 "archived_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.bigint "account_group_id" t.bigint "partnership_id"
t.index ["account_group_id"], name: "index_template_folders_on_account_group_id"
t.index ["account_id"], name: "index_template_folders_on_account_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 ["author_id"], name: "index_template_folders_on_author_id"
t.index ["partnership_id"], name: "index_template_folders_on_partnership_id"
end end
create_table "template_sharings", force: :cascade do |t| 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.text "preferences", null: false
t.boolean "shared_link", default: false, null: false t.boolean "shared_link", default: false, null: false
t.text "external_data_fields" t.text "external_data_fields"
t.bigint "account_group_id" t.bigint "partnership_id"
t.index ["account_group_id"], name: "index_templates_on_account_group_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", "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", "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 ["account_id"], name: "index_templates_on_account_id"
t.index ["author_id"], name: "index_templates_on_author_id" t.index ["author_id"], name: "index_templates_on_author_id"
t.index ["external_id"], name: "index_templates_on_external_id" t.index ["external_id"], name: "index_templates_on_external_id"
t.index ["folder_id"], name: "index_templates_on_folder_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 t.index ["slug"], name: "index_templates_on_slug", unique: true
end end
@ -457,8 +456,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_10_191227) do
t.integer "consumed_timestep" t.integer "consumed_timestep"
t.boolean "otp_required_for_login", default: false, null: false t.boolean "otp_required_for_login", default: false, null: false
t.integer "external_user_id" 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 ["account_id"], name: "index_users_on_account_id"
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["external_user_id"], name: "index_users_on_external_user_id", 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_configs", "accounts"
add_foreign_key "account_linked_accounts", "accounts" add_foreign_key "account_linked_accounts", "accounts"
add_foreign_key "account_linked_accounts", "accounts", column: "linked_account_id" 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_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "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" 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 "submissions", "users", column: "created_by_user_id"
add_foreign_key "submitters", "submissions" add_foreign_key "submitters", "submissions"
add_foreign_key "template_accesses", "templates" add_foreign_key "template_accesses", "templates"
add_foreign_key "template_folders", "account_groups"
add_foreign_key "template_folders", "accounts" 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_folders", "users", column: "author_id"
add_foreign_key "template_sharings", "templates" add_foreign_key "template_sharings", "templates"
add_foreign_key "templates", "account_groups"
add_foreign_key "templates", "accounts" add_foreign_key "templates", "accounts"
add_foreign_key "templates", "partnerships"
add_foreign_key "templates", "template_folders", column: "folder_id" add_foreign_key "templates", "template_folders", column: "folder_id"
add_foreign_key "templates", "users", column: "author_id" add_foreign_key "templates", "users", column: "author_id"
add_foreign_key "user_configs", "users" add_foreign_key "user_configs", "users"
add_foreign_key "users", "account_groups"
add_foreign_key "users", "accounts" add_foreign_key "users", "accounts"
add_foreign_key "webhook_urls", "accounts" add_foreign_key "webhook_urls", "accounts"
end end

@ -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

@ -4,10 +4,22 @@ module Abilities
module TemplateConditions module TemplateConditions
module_function 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? if user.account_id.present?
templates = Template.where(account_id: user.account_id) 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? return templates unless user.account.testing?
shared_ids = shared_ids =
@ -15,29 +27,81 @@ module Abilities
.select(:template_id) .select(:template_id)
Template.where(Template.arel_table[:id].in(Arel::Nodes::Union.new(templates.select(:id).arel, shared_ids.arel))) 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 else
# Partnership users and accounts don't have stored relationships
# Authorization happens at controller level via request context
Template.none Template.none
end end
end end
def entity(template, user:, ability: nil) def partnership_templates(request_context)
return true if template.account_id.blank? && template.account_group_id.blank? 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 def entity(template, user:, ability: nil, request_context: nil)
return template.account_group_id == user.account_group_id if template.account_group_id.present? 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 # 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 true if template.account_id == user.account_id
return false unless user.account&.linked_account_account return false unless user.account&.linked_account_account
return false if template.template_sharings.to_a.blank? return false if template.template_sharings.to_a.blank?
account_ids = [user.account_id, TemplateSharing::ALL_ID] account_ids = [user.account_id, TemplateSharing::ALL_ID]
template.template_sharings.to_a.any? do |e| template.template_sharings.to_a.any? do |sharing|
e.account_id.in?(account_ids) && (ability.nil? || e.ability == 'manage' || e.ability == ability) sharing.account_id.in?(account_ids) &&
(ability.nil? || sharing.ability == 'manage' || sharing.ability == ability)
end end
end end
def global_partnership_template?(template)
ExportLocation.global_partnership_id.present? &&
template.partnership_id == ExportLocation.global_partnership_id
end
end end
end end

@ -3,9 +3,15 @@
class Ability class Ability
include CanCan::Ability include CanCan::Ability
def initialize(user) def initialize(user, request_context = nil)
can %i[read create update], Template, Abilities::TemplateConditions.collection(user) do |template| can %i[read create update], Template,
Abilities::TemplateConditions.entity(template, user:, ability: 'manage') Abilities::TemplateConditions.collection(user, request_context: request_context) do |template|
Abilities::TemplateConditions.entity(
template,
user: user,
ability: 'manage',
request_context: request_context
)
end end
can :destroy, Template, account_id: user.account_id can :destroy, Template, account_id: user.account_id

@ -180,6 +180,11 @@ module SearchEntries
end end
def index_template(template) 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( sql = SearchEntry.sanitize_sql_array(
["SELECT to_tsvector(:text), to_tsvector('simple', :text)", ["SELECT to_tsvector(:text), to_tsvector('simple', :text)",
{ text: TextUtils.transliterate(template.name.to_s.downcase).delete("\0") }] { text: TextUtils.transliterate(template.name.to_s.downcase).delete("\0") }]

@ -18,7 +18,9 @@ module Submitters
module_function module_function
def call(submitter, keys = []) 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_button = find_safe_value(configs, AccountConfig::FORM_COMPLETED_BUTTON_KEY) || {}
completed_message = find_safe_value(configs, AccountConfig::FORM_COMPLETED_MESSAGE_KEY) || {} completed_message = find_safe_value(configs, AccountConfig::FORM_COMPLETED_MESSAGE_KEY) || {}

@ -9,9 +9,17 @@ module TemplateFolders
folders.where(TemplateFolder.arel_table[:name].lower.matches("%#{keyword.downcase}%")) folders.where(TemplateFolder.arel_table[:name].lower.matches("%#{keyword.downcase}%"))
end end
def find_or_create_by_name(author, name) def find_or_create_by_name(author, name, partnership: nil)
return author.account.default_template_folder if name.blank? || name == TemplateFolder::DEFAULT_NAME 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
end end

@ -5,19 +5,31 @@ module Templates
module_function module_function
# rubocop:disable Metrics, Style/CombinableLoops # rubocop:disable Metrics, Style/CombinableLoops
def call(original_template, author:, external_id: nil, name: nil, folder_name: nil) def call(original_template, author:, external_id: nil, name: nil, folder_name: nil,
template = original_template.account.templates.new 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.external_id = external_id
template.shared_link = original_template.shared_link template.shared_link = original_template.shared_link
template.author = author template.author = author
template.name = name.presence || "#{original_template.name} (#{I18n.t('clone')})" template.name = name.presence || "#{original_template.name} (#{I18n.t('clone')})"
if folder_name.present? template.folder = determine_template_folder(
template.folder = TemplateFolders.find_or_create_by_name(author, folder_name) original_template,
else target_account,
template.folder_id = original_template.folder_id target_partnership,
end author,
folder_name
)
template.submitters, template.fields, template.schema, template.preferences = template.submitters, template.fields, template.schema, template.preferences =
update_submitters_and_fields_and_schema(original_template.submitters.deep_dup, 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] [cloned_submitters, cloned_fields, cloned_schema, cloned_preferences]
end 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 # rubocop:enable Metrics, Style/CombinableLoops
end end
end end

@ -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

@ -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

@ -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

@ -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

@ -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

@ -6,7 +6,8 @@ FactoryBot.define do
created_by_user factory: %i[user] created_by_user factory: %i[user]
before(:create) do |submission, _| 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_fields = submission.template.fields
submission.template_schema = submission.template.schema submission.template_schema = submission.template.schema
submission.template_submitters = submission.template.submitters submission.template_submitters = submission.template.submitters

@ -357,5 +357,23 @@ FactoryBot.define do
create(:template_access, template:, user: ev.private_access_user || template.author) create(:template_access, template:, user: ev.private_access_user || template.author)
end end
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
end end

@ -8,5 +8,9 @@ FactoryBot.define do
password { 'password' } password { 'password' }
role { User::ADMIN_ROLE } role { User::ADMIN_ROLE }
email { Faker::Internet.email } email { Faker::Internet.email }
trait :with_partnership do
account { nil }
end
end end
end end

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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

@ -12,19 +12,13 @@
# uuid :string not null # uuid :string not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_group_id :bigint
# external_account_id :integer # external_account_id :integer
# #
# Indexes # Indexes
# #
# index_accounts_on_account_group_id (account_group_id)
# index_accounts_on_external_account_id (external_account_id) UNIQUE # index_accounts_on_external_account_id (external_account_id) UNIQUE
# index_accounts_on_uuid (uuid) UNIQUE # index_accounts_on_uuid (uuid) UNIQUE
# #
# Foreign Keys
#
# fk_rails_... (account_group_id => account_groups.id)
#
require 'rails_helper' require 'rails_helper'
RSpec.describe Account do RSpec.describe Account do
@ -43,16 +37,16 @@ RSpec.describe Account do
describe '.find_or_create_by_external_id' do describe '.find_or_create_by_external_id' do
let(:external_id) { 123 } let(:external_id) { 123 }
let(:attributes) { { name: 'Test Account' } } let(:name) { 'Test Account' }
it 'finds existing account by external_account_id' do it 'finds existing account by external_account_id' do
existing_account = create(:account, external_account_id: external_id) 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) expect(result).to eq(existing_account)
end end
it 'creates new account when none exists' do 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.external_account_id).to eq(external_id)
expect(result.name).to eq('Test Account') expect(result.name).to eq('Test Account')
end end
@ -69,17 +63,6 @@ RSpec.describe Account do
end end
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 describe '#default_template_folder' do
it 'creates default folder when none exists' do it 'creates default folder when none exists' do
account = create(:account) account = create(:account)

@ -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

@ -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

@ -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

@ -28,13 +28,11 @@
# uuid :string not null # uuid :string not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_group_id :bigint
# account_id :integer # account_id :integer
# external_user_id :integer # external_user_id :integer
# #
# Indexes # Indexes
# #
# index_users_on_account_group_id (account_group_id)
# index_users_on_account_id (account_id) # index_users_on_account_id (account_id)
# index_users_on_email (email) UNIQUE # index_users_on_email (email) UNIQUE
# index_users_on_external_user_id (external_user_id) UNIQUE # index_users_on_external_user_id (external_user_id) UNIQUE
@ -44,7 +42,6 @@
# #
# Foreign Keys # Foreign Keys
# #
# fk_rails_... (account_group_id => account_groups.id)
# fk_rails_... (account_id => accounts.id) # fk_rails_... (account_id => accounts.id)
# #
require 'rails_helper' require 'rails_helper'
@ -143,26 +140,4 @@ RSpec.describe User do
expect(user.friendly_name).to eq('john@example.com') expect(user.friendly_name).to eq('john@example.com')
end end
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 end

@ -32,5 +32,44 @@ describe 'External Auth API' do
expect(response).to have_http_status(:internal_server_error) expect(response).to have_http_status(:internal_server_error)
expect(response.parsed_body).to eq({ 'error' => 'Internal server error' }) expect(response.parsed_body).to eq({ 'error' => 'Internal server error' })
end 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
end end

@ -173,6 +173,41 @@ describe 'Templates API' do
expect(cloned_template.external_id).to eq('123456') expect(cloned_template.external_id).to eq('123456')
expect(response.parsed_body).to eq(JSON.parse(clone_template_body(cloned_template).to_json)) expect(response.parsed_body).to eq(JSON.parse(clone_template_body(cloned_template).to_json))
end 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 end
private private

@ -16,7 +16,9 @@ RSpec.describe ExternalAuthService do
context 'with account params' do context 'with account params' do
let(: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 user: user_params
} }
end end
@ -39,29 +41,80 @@ RSpec.describe ExternalAuthService do
end end
end end
context 'with account_group params' do context 'with partnership params' do
let(: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 user: user_params
} }
end 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 token = described_class.new(params).authenticate_user
expect(token).to be_present 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) expect(User.last.external_user_id).to eq(123)
end 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 end
context 'with invalid params' do 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 } params = { user: user_params }
expect { described_class.new(params).authenticate_user } 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 end
end end

@ -4,25 +4,27 @@ require 'rails_helper'
RSpec.describe TemplateService do RSpec.describe TemplateService do
describe '#assign_ownership' 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' } } let(:params) { { folder_name: 'Custom Folder' } }
context 'with account_group user' do context 'with partnership user' do
let(:account_group) { create(:account_group) } let(:partnership) { create(:partnership) }
let(:user) { create(:user, account: nil, account_group: account_group) } 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 = described_class.new(template, user, params)
service.assign_ownership service.assign_ownership
expect(template.account_group).to eq(account_group) expect(template.partnership).to eq(partnership)
expect(template.folder).to eq(account_group.default_template_folder) expect(template.folder.name).to eq('Custom Folder')
expect(template.folder.partnership).to eq(partnership)
end end
end end
context 'with account user' do context 'with account user' do
let(:account) { create(:account) } 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 it 'assigns account and finds/creates folder' do
service = described_class.new(template, user, params) service = described_class.new(template, user, params)
@ -33,15 +35,15 @@ RSpec.describe TemplateService do
end end
end end
context 'with user having neither account nor account_group' do context 'with user having neither account nor partnership' do
let(:user) { build(:user, account: nil, account_group: nil) } let(:user) { build(:user, account: nil) }
it 'does not assign ownership' do it 'does not assign ownership' do
service = described_class.new(template, user, params) service = described_class.new(template, user, params)
service.assign_ownership service.assign_ownership
expect(template.account).to be_nil expect(template.account).to be_nil
expect(template.account_group).to be_nil expect(template.partnership).to be_nil
end end
end end
end end

@ -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
Loading…
Cancel
Save