mirror of https://github.com/docusealco/docuseal
parent
961f09e092
commit
62a969d8fe
@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class McpController < ActionController::API
|
||||
before_action :authenticate_user!
|
||||
before_action :verify_mcp_enabled!
|
||||
|
||||
before_action do
|
||||
authorize!(:manage, :mcp)
|
||||
end
|
||||
|
||||
def call
|
||||
return head :ok if request.raw_post.blank?
|
||||
|
||||
body = JSON.parse(request.raw_post)
|
||||
|
||||
result = Mcp::HandleRequest.call(body, current_user, current_ability)
|
||||
|
||||
if result
|
||||
render json: result
|
||||
else
|
||||
head :accepted
|
||||
end
|
||||
rescue CanCan::AccessDenied
|
||||
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
|
||||
rescue JSON::ParserError
|
||||
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authenticate_user!
|
||||
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
|
||||
end
|
||||
|
||||
def verify_mcp_enabled!
|
||||
return if Docuseal.multitenant?
|
||||
|
||||
return if AccountConfig.exists?(account_id: current_user.account_id,
|
||||
key: AccountConfig::ENABLE_MCP_KEY,
|
||||
value: true)
|
||||
|
||||
render json: { error: 'MCP is disabled' }, status: :forbidden
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= user_from_api_key
|
||||
end
|
||||
|
||||
def user_from_api_key
|
||||
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
|
||||
|
||||
return if token.blank?
|
||||
|
||||
sha256 = Digest::SHA256.hexdigest(token)
|
||||
|
||||
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class McpSettingsController < ApplicationController
|
||||
load_and_authorize_resource :mcp_token, parent: false
|
||||
|
||||
before_action do
|
||||
authorize!(:manage, :mcp)
|
||||
end
|
||||
|
||||
def index
|
||||
@mcp_tokens = @mcp_tokens.active.order(id: :desc)
|
||||
end
|
||||
|
||||
def create
|
||||
@mcp_token = current_user.mcp_tokens.new(mcp_token_params)
|
||||
|
||||
if @mcp_token.save
|
||||
flash[:mcp_token] = @mcp_token.token
|
||||
|
||||
redirect_back fallback_location: settings_mcp_index_path, notice: I18n.t('mcp_token_has_been_created')
|
||||
else
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'mcp_settings/new'), status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@mcp_token.update!(archived_at: Time.current)
|
||||
|
||||
redirect_back fallback_location: settings_mcp_index_path, notice: I18n.t('mcp_token_has_been_removed')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mcp_token_params
|
||||
params.require(:mcp_token).permit(:name)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: mcp_tokens
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# archived_at :datetime
|
||||
# name :string not null
|
||||
# sha256 :string not null
|
||||
# token_prefix :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_mcp_tokens_on_sha256 (sha256) UNIQUE
|
||||
# index_mcp_tokens_on_user_id (user_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
class McpToken < ApplicationRecord
|
||||
TOKEN_LENGTH = 43
|
||||
|
||||
belongs_to :user
|
||||
|
||||
before_validation :set_sha256_and_token_prefix, on: :create
|
||||
|
||||
attribute :token, :string, default: -> { SecureRandom.base58(TOKEN_LENGTH) }
|
||||
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
|
||||
private
|
||||
|
||||
def set_sha256_and_token_prefix
|
||||
self.sha256 = Digest::SHA256.hexdigest(token)
|
||||
self.token_prefix = token[0, 5]
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,112 @@
|
||||
<div class="flex-wrap space-y-4 md:flex md:flex-nowrap md:space-y-0 md:space-x-10">
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="md:flex-grow">
|
||||
<div class="flex flex-col md:flex-row md:flex-wrap gap-2 md:justify-between md:items-end mb-4 min-h-12">
|
||||
<h1 class="text-4xl font-bold">
|
||||
<%= t('mcp_server') %>
|
||||
</h1>
|
||||
<div class="flex flex-col md:flex-row gap-y-2 gap-x-4 md:items-center">
|
||||
<div class="tooltip">
|
||||
<%= link_to new_settings_mcp_path, class: 'btn btn-primary btn-md gap-2 w-full md:w-fit', data: { turbo_frame: 'modal' } do %>
|
||||
<%= svg_icon('plus', class: 'w-6 h-6') %>
|
||||
<span><%= t('new_token') %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% if flash[:mcp_token].present? %>
|
||||
<div class="space-y-4 mb-4">
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-6">
|
||||
<label for="mcp_token" class="text-sm font-semibold">
|
||||
<%= t('please_copy_the_token_below_now_as_it_wont_be_shown_again') %>:
|
||||
</label>
|
||||
<div class="flex w-full space-x-4">
|
||||
<input id="mcp_token" type="text" value="<%= flash[:mcp_token] %>" class="input font-mono input-bordered w-full" autocomplete="off" readonly>
|
||||
<%= render 'shared/clipboard_copy', icon: 'copy', text: flash[:mcp_token], class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<p class="text-2xl font-bold">
|
||||
<%= t('instructions') %>
|
||||
</p>
|
||||
<div class="card bg-base-200/60 border-2 border-info">
|
||||
<div class="card-body p-6">
|
||||
<p class="text-2xl font-semibold"><%= t('connect_to_docuseal_mcp') %></p>
|
||||
<p class="text-lg"><%= t('add_the_following_to_your_mcp_client_configuration') %>:</p>
|
||||
<div class="mockup-code overflow-hidden">
|
||||
<% text = JSON.pretty_generate({ mcpServers: { docuseal: { type: 'http', url: "#{root_url(Docuseal.default_url_options)}mcp", headers: { Authorization: "Bearer #{flash[:mcp_token]}" } } } }).strip %>
|
||||
<span class="top-0 right-0 absolute">
|
||||
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
|
||||
</span>
|
||||
<pre class="before:!m-0 pl-4 pb-4"><code class="overflow-hidden w-full"><%== HighlightCode.call(text, 'JSON', theme: 'base16.dark') %></code></pre>
|
||||
</div>
|
||||
<p class="text-lg"><%= t('works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client') %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full table-lg rounded-b-none overflow-hidden">
|
||||
<thead class="bg-base-200">
|
||||
<tr class="text-neutral uppercase">
|
||||
<th>
|
||||
<%= t('name') %>
|
||||
</th>
|
||||
<th>
|
||||
<%= t('token') %>
|
||||
</th>
|
||||
<th>
|
||||
<%= t('created_at') %>
|
||||
</th>
|
||||
<th class="text-right" width="1px">
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @mcp_tokens.each do |mcp_token| %>
|
||||
<tr scope="row">
|
||||
<td>
|
||||
<%= mcp_token.name %>
|
||||
</td>
|
||||
<td>
|
||||
<% if flash[:mcp_token].present? && mcp_token.token_prefix == flash[:mcp_token][0, 5] %>
|
||||
<%= flash[:mcp_token] %>
|
||||
<% else %>
|
||||
<%= "#{mcp_token.token_prefix}#{'*' * 38}" %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<%= l(mcp_token.created_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>
|
||||
</td>
|
||||
<td class="flex items-center space-x-2 justify-end">
|
||||
<%= button_to settings_mcp_path(mcp_token), method: :delete, class: 'btn btn-outline btn-error btn-xs', title: t('remove'), data: { turbo_confirm: t('are_you_sure_') } do %>
|
||||
<%= t('remove') %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ENABLE_MCP_KEY) %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('enable_mcp_server') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value == true %>
|
||||
</submit-form>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,13 @@
|
||||
<%= render 'shared/turbo_modal', title: t('new_token') do %>
|
||||
<%= form_for @mcp_token, url: settings_mcp_index_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
|
||||
<div class="space-y-4">
|
||||
<div class="w-full">
|
||||
<%= f.label :name, t('name'), class: 'label' %>
|
||||
<%= f.text_field :name, required: true, class: 'base-input w-full', dir: 'auto' %>
|
||||
</div>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title, class: 'base-button' %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddPkceToDoorkeeperAccessGrants < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :oauth_access_grants, :code_challenge, :string, null: true
|
||||
add_column :oauth_access_grants, :code_challenge_method, :string, null: true
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateMcpTokens < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :mcp_tokens do |t|
|
||||
t.references :user, null: false, foreign_key: true, index: true
|
||||
t.string :name, null: false
|
||||
t.string :sha256, null: false, index: { unique: true }
|
||||
t.string :token_prefix, null: false
|
||||
t.datetime :archived_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,65 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
module HandleRequest
|
||||
TOOLS = [
|
||||
Mcp::Tools::SearchTemplates,
|
||||
Mcp::Tools::CreateTemplate,
|
||||
Mcp::Tools::SendDocuments,
|
||||
Mcp::Tools::SearchDocuments
|
||||
].freeze
|
||||
|
||||
TOOLS_SCHEMA = TOOLS.map { |t| t::SCHEMA }
|
||||
|
||||
TOOLS_INDEX = TOOLS.index_by { |t| t::SCHEMA[:name] }
|
||||
|
||||
module_function
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def call(body, current_user, current_ability)
|
||||
case body['method']
|
||||
when 'initialize'
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: body['id'],
|
||||
result: {
|
||||
protocolVersion: '2025-11-25',
|
||||
serverInfo: {
|
||||
name: 'DocuSeal',
|
||||
version: Docuseal.version.to_s
|
||||
},
|
||||
capabilities: {
|
||||
tools: {
|
||||
listChanged: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
when 'notifications/initialized'
|
||||
nil
|
||||
when 'ping'
|
||||
{ jsonrpc: '2.0', id: body['id'], result: {} }
|
||||
when 'tools/list'
|
||||
{ jsonrpc: '2.0', id: body['id'], result: { tools: TOOLS_SCHEMA } }
|
||||
when 'tools/call'
|
||||
tool = TOOLS_INDEX[body.dig('params', 'name')]
|
||||
|
||||
raise "Unknown tool: #{body.dig('params', 'name')}" unless tool
|
||||
|
||||
result = tool.call(body.dig('params', 'arguments') || {}, current_user, current_ability)
|
||||
|
||||
{ jsonrpc: '2.0', id: body['id'], result: }
|
||||
else
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: body['id'],
|
||||
error: {
|
||||
code: -32_601,
|
||||
message: "Method not found: #{body['method']}"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,110 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
module Tools
|
||||
module CreateTemplate
|
||||
SCHEMA = {
|
||||
name: 'create_template',
|
||||
title: 'Create Template',
|
||||
description: 'Create a template from a PDF. Provide a URL or base64-encoded file content.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'URL of the document file to upload'
|
||||
},
|
||||
file: {
|
||||
type: 'string',
|
||||
description: 'Base64-encoded file content'
|
||||
},
|
||||
filename: {
|
||||
type: 'string',
|
||||
description: 'Filename with extension (required when using file)'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Template name (defaults to filename)'
|
||||
}
|
||||
}
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false
|
||||
}
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||
def call(arguments, current_user, current_ability)
|
||||
current_ability.authorize!(:create, Template.new(account_id: current_user.account_id, author: current_user))
|
||||
|
||||
account = current_user.account
|
||||
|
||||
if arguments['file'].present?
|
||||
tempfile = Tempfile.new
|
||||
tempfile.binmode
|
||||
tempfile.write(Base64.decode64(arguments['file']))
|
||||
tempfile.rewind
|
||||
|
||||
filename = arguments['filename'] || 'document.pdf'
|
||||
elsif arguments['url'].present?
|
||||
tempfile = Tempfile.new
|
||||
tempfile.binmode
|
||||
tempfile.write(DownloadUtils.call(arguments['url'], validate: true).body)
|
||||
tempfile.rewind
|
||||
|
||||
filename = File.basename(URI.decode_www_form_component(arguments['url']))
|
||||
else
|
||||
return { content: [{ type: 'text', text: 'Provide either url or file' }], isError: true }
|
||||
end
|
||||
|
||||
file = ActionDispatch::Http::UploadedFile.new(
|
||||
tempfile:,
|
||||
filename:,
|
||||
type: Marcel::MimeType.for(tempfile)
|
||||
)
|
||||
|
||||
template = Template.new(
|
||||
account:,
|
||||
author: current_user,
|
||||
folder: account.default_template_folder,
|
||||
name: arguments['name'].presence || File.basename(filename, '.*')
|
||||
)
|
||||
|
||||
template.save!
|
||||
|
||||
documents, = Templates::CreateAttachments.call(template, { files: [file] }, extract_fields: true)
|
||||
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
|
||||
|
||||
if template.fields.blank?
|
||||
template.fields = Templates::ProcessDocument.normalize_attachment_fields(template, documents)
|
||||
end
|
||||
|
||||
template.update!(schema:)
|
||||
|
||||
WebhookUrls.enqueue_events(template, 'template.created')
|
||||
|
||||
SearchEntries.enqueue_reindex(template)
|
||||
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
edit_url: Rails.application.routes.url_helpers.edit_template_url(template,
|
||||
**Docuseal.default_url_options)
|
||||
}.to_json
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,65 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
module Tools
|
||||
module SearchDocuments
|
||||
SCHEMA = {
|
||||
name: 'search_documents',
|
||||
title: 'Search Documents',
|
||||
description: 'Search signed or pending documents by submitter name, email, phone, or template name',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: {
|
||||
type: 'string',
|
||||
description: 'Search by submitter name, email, phone, or template name'
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description: 'The number of results to return (default 10)'
|
||||
}
|
||||
},
|
||||
required: %w[q]
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def call(arguments, current_user, current_ability)
|
||||
submissions = Submissions.search(current_user, Submission.accessible_by(current_ability).active,
|
||||
arguments['q'], search_template: true)
|
||||
|
||||
limit = arguments.fetch('limit', 10).to_i
|
||||
limit = 10 if limit <= 0
|
||||
limit = [limit, 100].min
|
||||
submissions = submissions.preload(:submitters, :template)
|
||||
.order(id: :desc)
|
||||
.limit(limit)
|
||||
|
||||
data = submissions.map do |submission|
|
||||
url = Rails.application.routes.url_helpers.submission_url(
|
||||
submission.id, **Docuseal.default_url_options
|
||||
)
|
||||
|
||||
{
|
||||
id: submission.id,
|
||||
template_name: submission.template&.name,
|
||||
status: Submissions::SerializeForApi.build_status(submission, submission.submitters),
|
||||
submitters: submission.submitters.map do |s|
|
||||
{ email: s.email, name: s.name, phone: s.phone, status: s.status }
|
||||
end,
|
||||
documents_url: url
|
||||
}
|
||||
end
|
||||
|
||||
{ content: [{ type: 'text', text: data.to_json }] }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
module Tools
|
||||
module SearchTemplates
|
||||
SCHEMA = {
|
||||
name: 'search_templates',
|
||||
title: 'Search Templates',
|
||||
description: 'Search document templates by name',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: {
|
||||
type: 'string',
|
||||
description: 'Search query to filter templates by name'
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description: 'The number of templates to return (default 10)'
|
||||
}
|
||||
},
|
||||
required: %w[q]
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false
|
||||
}
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def call(arguments, current_user, current_ability)
|
||||
templates = Templates.search(current_user, Template.accessible_by(current_ability).active, arguments['q'])
|
||||
|
||||
limit = arguments.fetch('limit', 10).to_i
|
||||
limit = 10 if limit <= 0
|
||||
limit = [limit, 100].min
|
||||
templates = templates.order(id: :desc).limit(limit)
|
||||
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: templates.map { |t| { id: t.id, name: t.name } }.to_json
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,114 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Mcp
|
||||
module Tools
|
||||
module SendDocuments
|
||||
SCHEMA = {
|
||||
name: 'send_documents',
|
||||
title: 'Send Documents',
|
||||
description: 'Send a document template for signing to specified submitters',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
template_id: {
|
||||
type: 'integer',
|
||||
description: 'Template identifier'
|
||||
},
|
||||
submitters: {
|
||||
type: 'array',
|
||||
description: 'The list of submitters (signers)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: {
|
||||
type: 'string',
|
||||
description: 'Submitter email address'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Submitter name'
|
||||
},
|
||||
phone: {
|
||||
type: 'string',
|
||||
description: 'Submitter phone number in E.164 format'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
required: %w[template_id submitters]
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true
|
||||
}
|
||||
}.freeze
|
||||
|
||||
module_function
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def call(arguments, current_user, current_ability)
|
||||
template = Template.accessible_by(current_ability).find_by(id: arguments['template_id'])
|
||||
|
||||
return { content: [{ type: 'text', text: 'Template not found' }], isError: true } unless template
|
||||
|
||||
current_ability.authorize!(:create, Submission.new(template:, account_id: current_user.account_id))
|
||||
|
||||
return { content: [{ type: 'text', text: 'Template has no fields' }], isError: true } if template.fields.blank?
|
||||
|
||||
submitters = (arguments['submitters'] || []).map do |s|
|
||||
s.slice('email', 'name', 'role', 'phone')
|
||||
.compact_blank
|
||||
.with_indifferent_access
|
||||
end
|
||||
|
||||
submissions = Submissions.create_from_submitters(
|
||||
template:,
|
||||
user: current_user,
|
||||
source: :api,
|
||||
submitters_order: 'random',
|
||||
submissions_attrs: { submitters: submitters },
|
||||
params: { 'send_email' => true, 'submitters' => submitters }
|
||||
)
|
||||
|
||||
if submissions.blank?
|
||||
return { content: [{ type: 'text', text: 'No valid submitters provided' }], isError: true }
|
||||
end
|
||||
|
||||
WebhookUrls.enqueue_events(submissions, 'submission.created')
|
||||
|
||||
Submissions.send_signature_requests(submissions)
|
||||
|
||||
submissions.each do |submission|
|
||||
submission.submitters.each do |submitter|
|
||||
next unless submitter.completed_at?
|
||||
|
||||
ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id,
|
||||
'send_invitation_email' => false)
|
||||
end
|
||||
end
|
||||
|
||||
SearchEntries.enqueue_reindex(submissions)
|
||||
|
||||
submission = submissions.first
|
||||
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: {
|
||||
id: submission.id,
|
||||
status: 'pending'
|
||||
}.to_json
|
||||
}
|
||||
]
|
||||
}
|
||||
rescue Submissions::CreateFromSubmitters::BaseError => e
|
||||
{ content: [{ type: 'text', text: e.message }], isError: true }
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in new issue