diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2eb09ddc..925d8f6a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -30,7 +30,9 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Create .version file - run: echo ${{ github.ref_name }} > .version + env: + REF_NAME: ${{ github.ref_name }} + run: echo "$REF_NAME" > .version - name: Login to Docker Hub uses: docker/login-action@v3 diff --git a/Gemfile.lock b/Gemfile.lock index 8f63e42e..816c8acd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -149,7 +149,7 @@ GEM crack (1.0.1) bigdecimal rexml - crass (1.0.6) + crass (1.0.7) csv (3.3.5) csv-safe (3.3.1) csv (~> 3.0) @@ -293,7 +293,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.25.1) + loofah (2.25.2) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.9.0) @@ -404,8 +404,8 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.7.0) - loofah (~> 2.25) + rails-html-sanitizer (1.7.1) + loofah (~> 2.25, >= 2.25.2) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (8.1.0) i18n (>= 0.7, < 2) diff --git a/app/controllers/esign_settings_controller.rb b/app/controllers/esign_settings_controller.rb index 78630f45..abdc402f 100644 --- a/app/controllers/esign_settings_controller.rb +++ b/app/controllers/esign_settings_controller.rb @@ -14,8 +14,7 @@ class EsignSettingsController < ApplicationController prepend_before_action :maybe_redirect_com, only: %i[show] before_action :load_encrypted_config - authorize_resource :encrypted_config, parent: false, only: %i[new create] - authorize_resource :encrypted_config, only: %i[update destroy show] + authorize_resource :encrypted_config, parent: false def show cert_data = @encrypted_config.value || {} diff --git a/app/controllers/mcp/create_template_controller.rb b/app/controllers/mcp/create_template_controller.rb new file mode 100644 index 00000000..b4c06a63 --- /dev/null +++ b/app/controllers/mcp/create_template_controller.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Mcp + class CreateTemplateController < McpBaseController + SCHEMA = { + name: 'create_template', + title: 'Create Template', + description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \ + 'to create an empty template and receive an edit URL where the file can be uploaded via the UI.', + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Template name (used as the template name and required when url is not provided)' + }, + url: { + type: 'string', + description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \ + 'created and the returned edit_url can be used to upload a file via the UI.' + } + }, + required: %w[name] + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true + } + }.freeze + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def call + account = current_user.account + + @template = Template.new( + account:, + author: current_user, + folder: account.default_template_folder, + source: :mcp, + name: mcp_params['name'].to_s.presence || 'New Template', + fields: [], + schema: [] + ) + + authorize!(:create, @template) + + if mcp_params['url'].present? + tempfile = Tempfile.new + tempfile.binmode + tempfile.write(DownloadUtils.call(mcp_params['url'], validate: true).body) + tempfile.rewind + + filename = File.basename(URI.decode_www_form_component(mcp_params['url'])) + + file = ActionDispatch::Http::UploadedFile.new( + tempfile:, + filename:, + type: Marcel::MimeType.for(tempfile) + ) + + @template.name = mcp_params['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:) + else + @template.save! + end + + WebhookUrls.enqueue_events(@template, 'template.created') + + SearchEntries.enqueue_reindex(@template) + + render_tool_result( + id: @template.id, + name: @template.name, + edit_url: edit_template_url(@template) + ) + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + end +end diff --git a/app/controllers/mcp/load_template_controller.rb b/app/controllers/mcp/load_template_controller.rb new file mode 100644 index 00000000..cefd688a --- /dev/null +++ b/app/controllers/mcp/load_template_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Mcp + class LoadTemplateController < McpBaseController + SCHEMA = { + name: 'load_template', + title: 'Load Template', + description: 'Load a template with its fields. Each field includes name, type, and the signing role name.', + inputSchema: { + type: 'object', + properties: { + template_id: { + type: 'integer', + description: 'Template identifier' + } + }, + required: %w[template_id] + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + } + }.freeze + + def call + @template = Template.accessible_by(current_ability).find(mcp_params['template_id']) + + authorize!(:read, @template) + + submitters_index = @template.submitters.index_by { |s| s['uuid'] } + + roles = @template.submitters.pluck('name') + + fields = @template.fields.filter_map do |field| + next if field['name'].blank? + + { + name: field['name'], + type: field['type'], + role: submitters_index[field['submitter_uuid']]&.dig('name') + } + end + + render_tool_result( + id: @template.id, + name: @template.name, + roles: roles, + fields: fields + ) + end + end +end diff --git a/app/controllers/mcp/mcp_base_controller.rb b/app/controllers/mcp/mcp_base_controller.rb new file mode 100644 index 00000000..996c350c --- /dev/null +++ b/app/controllers/mcp/mcp_base_controller.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Mcp + class McpBaseController < ActionController::API + wrap_parameters false + + before_action :authenticate_user! + before_action :verify_mcp_enabled! + check_authorization + + before_action do + raise CanCan::AccessDenied unless can?(:manage, :mcp) + end + + rescue_from CanCan::AccessDenied do + render_error(-32_603, 'Forbidden', status: :forbidden) + end + + rescue_from ActiveRecord::RecordNotFound do + render_tool_error('Not found') + end + + private + + def default_url_options + Docuseal.default_url_options + end + + def mcp_body + request.request_parameters + end + + def mcp_params + mcp_body.dig('params', 'arguments') || {} + end + + def render_result(result) + render json: { jsonrpc: '2.0', id: mcp_body['id'], result: } + end + + def render_error(code, message, id: nil, status: :ok) + render json: { jsonrpc: '2.0', id:, error: { code:, message: } }, status: + end + + def render_tool_result(data) + render_result(content: [{ type: 'text', text: data.to_json }]) + end + + def render_tool_error(message) + render_result(content: [{ type: 'text', text: message }], isError: true) + end + + 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 +end diff --git a/app/controllers/mcp/protocol_controller.rb b/app/controllers/mcp/protocol_controller.rb new file mode 100644 index 00000000..0cf07584 --- /dev/null +++ b/app/controllers/mcp/protocol_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Mcp + class ProtocolController < McpBaseController + skip_authorization_check + + def ok + head :ok + end + + def initialize_request + render_result( + protocolVersion: '2025-11-25', + serverInfo: { + name: 'DocuSeal', + version: Docuseal.version.to_s + }, + capabilities: { + tools: { + listChanged: false + } + } + ) + end + + def initialized_notification + head :accepted + end + + def ping + render_result({}) + end + + def tools_list + render_result(tools: McpController::TOOLS) + end + + def method_not_found + render_error(-32_601, "Method not found: #{mcp_body['method']}", id: mcp_body['id']) + end + + def tool_not_found + render_error(-32_602, "Unknown tool: #{mcp_body.dig('params', 'name')}", id: mcp_body['id']) + end + + def parse_error + render_error(-32_700, 'Parse error', status: :bad_request) + end + end +end diff --git a/app/controllers/mcp/search_documents_controller.rb b/app/controllers/mcp/search_documents_controller.rb new file mode 100644 index 00000000..f58c0e17 --- /dev/null +++ b/app/controllers/mcp/search_documents_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Mcp + class SearchDocumentsController < McpBaseController + 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 + + def call + authorize!(:read, Submission) + + submissions = Submissions.search(current_user, Submission.accessible_by(current_ability).active, + mcp_params['q'], search_template: true) + + limit = mcp_params.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| + { + 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: submission_url(submission.id) + } + end + + render_tool_result(data) + end + end +end diff --git a/app/controllers/mcp/search_templates_controller.rb b/app/controllers/mcp/search_templates_controller.rb new file mode 100644 index 00000000..bc630b35 --- /dev/null +++ b/app/controllers/mcp/search_templates_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Mcp + class SearchTemplatesController < McpBaseController + 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 + + def call + authorize!(:read, Template) + + templates = Templates.search(current_user, Template.accessible_by(current_ability).active, mcp_params['q']) + + limit = mcp_params.fetch('limit', 10).to_i + limit = 10 if limit <= 0 + limit = [limit, 100].min + templates = templates.order(id: :desc).limit(limit) + + render_tool_result(templates.map { |t| { id: t.id, name: t.name } }) + end + end +end diff --git a/app/controllers/mcp/send_documents_controller.rb b/app/controllers/mcp/send_documents_controller.rb new file mode 100644 index 00000000..7fadc8a1 --- /dev/null +++ b/app/controllers/mcp/send_documents_controller.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Mcp + class SendDocumentsController < McpBaseController + 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' + }, + role: { + type: 'string', + description: 'Signing role name from the template' + }, + fields: { + type: 'array', + description: 'Prefill field values for this submitter (fields become readonly)', + items: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Field name' + }, + value: { + description: 'Prefilled value for the field' + } + }, + required: %w[name value] + } + } + } + } + } + }, + required: %w[template_id submitters] + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true + } + }.freeze + + # rubocop:disable Metrics + def call + @template = Template.accessible_by(current_ability).find(mcp_params['template_id']) + + authorize!(:read, @template) + + return render_tool_error('Template has been archived') if @template.archived_at? + + authorize!(:create, Submission.new(template: @template, account_id: current_user.account_id)) + + return render_tool_error('Template has no fields') if @template.fields.blank? + + submitters = (mcp_params['submitters'] || []).map do |s| + attrs = s.slice('email', 'name', 'role', 'phone').compact_blank + + fields = Array.wrap(s['fields']).filter_map do |f| + next if f['name'].blank? + + { 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true } + end + + attrs['fields'] = fields if fields.present? + + attrs.with_indifferent_access + end + + submissions = Submissions.create_from_submitters( + template: @template, + user: current_user, + source: :mcp, + submitters_order: @template.preferences['submitters_order'].presence || 'random', + submissions_attrs: { submitters: }, + params: { 'send_email' => true, 'submitters' => submitters } + ) + + return render_tool_error('No valid submitters provided') if submissions.blank? + + WebhookUrls.enqueue_events(submissions, 'submission.created') + + Submissions.send_signature_requests(submissions) + + SearchEntries.enqueue_reindex(submissions) + + submission = submissions.first + + render_tool_result(id: submission.id, status: 'pending') + rescue Submissions::CreateFromSubmitters::BaseError => e + render_tool_error(e.message) + end + # rubocop:enable Metrics + end +end diff --git a/app/controllers/mcp_controller.rb b/app/controllers/mcp_controller.rb index 8d7ca288..7f0db5e8 100644 --- a/app/controllers/mcp_controller.rb +++ b/app/controllers/mcp_controller.rb @@ -1,58 +1,44 @@ # frozen_string_literal: true -class McpController < ActionController::API - before_action :authenticate_user! - before_action :verify_mcp_enabled! +class McpController < ActionController::Metal + TOOL_CONTROLLERS = { + 'search_templates' => Mcp::SearchTemplatesController, + 'load_template' => Mcp::LoadTemplateController, + 'create_template' => Mcp::CreateTemplateController, + 'send_documents' => Mcp::SendDocumentsController, + 'search_documents' => Mcp::SearchDocumentsController + }.freeze - before_action do - authorize!(:manage, :mcp) - end + TOOLS = TOOL_CONTROLLERS.map { |_, controller| controller::SCHEMA }.freeze def call - return head :ok if request.raw_post.blank? + return Mcp::ProtocolController.dispatch(:ok, request, response) if request.raw_post.blank? body = JSON.parse(request.raw_post) + body = nil unless body.is_a?(Hash) - result = Mcp::HandleRequest.call(body, current_user, current_ability) + request.request_parameters = body || {} - 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 + action = + case body&.dig('method') + when 'initialize' then :initialize_request + when 'notifications/initialized' then :initialized_notification + when 'ping' then :ping + when 'tools/list' then :tools_list + when 'tools/call' + tool = TOOL_CONTROLLERS[body.dig('params', 'name')] - private + return tool.dispatch(:call, request, response) if tool - def authenticate_user! - render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user - end + :tool_not_found + else + :method_not_found + 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) + Mcp::ProtocolController.dispatch(action, request, response) + rescue JSON::ParserError + request.request_parameters = {} - User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil }) + Mcp::ProtocolController.dispatch(:parse_error, request, response) end end diff --git a/app/javascript/elements/html_editor.js b/app/javascript/elements/html_editor.js index 8c9a2d2c..29a5f40e 100644 --- a/app/javascript/elements/html_editor.js +++ b/app/javascript/elements/html_editor.js @@ -106,11 +106,29 @@ img.ProseMirror-separator { } `) +const DROP_ATTRS = [ + 'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster', + 'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile' +] + +const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i + +function isSafeAttr (name, value) { + const lowerName = name.toLowerCase() + + if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false + if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false + + return true +} + function collectDomAttrs (dom) { const attrs = {} for (let i = 0; i < dom.attributes.length; i++) { - attrs[dom.attributes[i].name] = dom.attributes[i].value + const { name, value } = dom.attributes[i] + + if (isSafeAttr(name, value)) attrs[name] = value } return { htmlAttrs: attrs } diff --git a/app/javascript/elements/search_input.js b/app/javascript/elements/search_input.js index 585f7122..e07e6a5c 100644 --- a/app/javascript/elements/search_input.js +++ b/app/javascript/elements/search_input.js @@ -1,19 +1,5 @@ export default class extends HTMLElement { connectedCallback () { - this.input.addEventListener('focus', () => { - if (this.title) { - this.title.classList.add('hidden', 'md:block') - this.input.classList.add('w-60') - } - }) - - this.input.addEventListener('blur', (e) => { - if (this.title && !e.target.value) { - this.title.classList.remove('hidden') - this.input.classList.remove('w-60') - } - }) - this.button.addEventListener('click', (event) => { if (!this.input.value && document.activeElement !== this.input) { event.preventDefault() @@ -21,14 +7,20 @@ export default class extends HTMLElement { this.input.focus() } }) + + document.addEventListener('turbo:before-cache', this.onBeforeCache) } - get input () { - return this.querySelector('input') + disconnectedCallback () { + document.removeEventListener('turbo:before-cache', this.onBeforeCache) + } + + onBeforeCache = () => { + this.input.value = this.input.getAttribute('value') || '' } - get title () { - return document.querySelector(this.dataset.title) + get input () { + return this.querySelector('input') } get button () { diff --git a/app/javascript/submission_form/initials_step.vue b/app/javascript/submission_form/initials_step.vue index b7b2c05a..1e9f1b67 100644 --- a/app/javascript/submission_form/initials_step.vue +++ b/app/javascript/submission_form/initials_step.vue @@ -169,7 +169,7 @@ class="base-input !text-2xl w-full mt-6 text-center" :required="field.required && !isInitialsStarted" :aria-label="field.name || t('initials')" - :placeholder="`${t('type_initial_here')}...`" + :placeholder="`${t('type_initial_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`" type="text" @focus="$emit('focus')" @input="updateWrittenInitials" @@ -293,7 +293,7 @@ export default { if (!this.isDrawInitials) { this.$nextTick(() => { - if (this.$refs.textInput) { + if (this.$refs.textInput && this.field.required === true) { this.initTextInitial() } }) diff --git a/app/javascript/submission_form/signature_step.vue b/app/javascript/submission_form/signature_step.vue index 68f1dc18..df5c7974 100644 --- a/app/javascript/submission_form/signature_step.vue +++ b/app/javascript/submission_form/signature_step.vue @@ -247,7 +247,7 @@ class="base-input !text-2xl w-full mt-6" :required="field.required && !isSignatureStarted" :aria-label="field.name || t('signature')" - :placeholder="`${t('type_signature_here')}...`" + :placeholder="`${t('type_signature_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`" type="text" @input="updateWrittenSignature" > @@ -535,7 +535,7 @@ export default { this.$nextTick(() => this.drawSignatureSrc()) } else if (this.isTextSignature) { this.$nextTick(() => { - if (this.$refs.textInput) { + if (this.$refs.textInput && this.field.required === true) { this.initTypedSignature() } }) diff --git a/app/javascript/template_builder/dynamic_editor.js b/app/javascript/template_builder/dynamic_editor.js index d83062a4..cbde1587 100644 --- a/app/javascript/template_builder/dynamic_editor.js +++ b/app/javascript/template_builder/dynamic_editor.js @@ -98,11 +98,29 @@ dynamic-variable { overflow-wrap: anywhere; }`) +const DROP_ATTRS = [ + 'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster', + 'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile' +] + +const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i + +function isSafeAttr (name, value) { + const lowerName = name.toLowerCase() + + if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false + if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false + + return true +} + function collectDomAttrs (dom) { const attrs = {} for (let i = 0; i < dom.attributes.length; i++) { - attrs[dom.attributes[i].name] = dom.attributes[i].value + const { name, value } = dom.attributes[i] + + if (isSafeAttr(name, value)) attrs[name] = value } return { htmlAttrs: attrs } diff --git a/app/javascript/template_builder/dynamic_section.vue b/app/javascript/template_builder/dynamic_section.vue index 9fd81246..852a7a56 100644 --- a/app/javascript/template_builder/dynamic_section.vue +++ b/app/javascript/template_builder/dynamic_section.vue @@ -646,9 +646,7 @@ export default { return } - const container = document.createElement('div') - - container.innerHTML = clipboardHtml + const container = new DOMParser().parseFromString(clipboardHtml, 'text/html').body const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')] diff --git a/app/views/shared/_github.html.erb b/app/views/shared/_github.html.erb index 51083118..a27f7f96 100644 --- a/app/views/shared/_github.html.erb +++ b/app/views/shared/_github.html.erb @@ -1,6 +1,6 @@ <%= svg_icon('start', class: 'h-3 w-3') %> - 17k + 18k diff --git a/app/views/shared/_search_input.html.erb b/app/views/shared/_search_input.html.erb index 1868c658..73f82406 100644 --- a/app/views/shared/_search_input.html.erb +++ b/app/views/shared/_search_input.html.erb @@ -1,4 +1,4 @@ -
+ <% Submissions::Filter::ALLOWED_PARAMS.each do |key| %> <% if params[key].present? %> @@ -14,8 +14,8 @@ <% end %> - - + + <% end %> -
+
<% unless show_dropzone %> <%= render 'templates/dashboard_dropzone', style: 'height: 114px' %> <% end %> <%= render 'templates/dashboard_folder_dropzone', style: 'height: 114px' %> -
+
<% if has_archived || @pagy.count.nil? || @pagy.count > 0 || @template_folders.present? %>
<%= render 'dashboard/toggle_view', selected: 'templates' %> @@ -22,7 +22,7 @@ <%= t('document_templates_html') %>
-
+
<% if params[:q].present? || @pagy.count.nil? || @pagy.count > 1 || @template_folders.present? %> <%= render 'shared/search_input' %> <% end %> diff --git a/app/views/templates_shared/index.html.erb b/app/views/templates_shared/index.html.erb index f04e5ecb..3148c22e 100644 --- a/app/views/templates_shared/index.html.erb +++ b/app/views/templates_shared/index.html.erb @@ -1,18 +1,18 @@
- <%= link_to(@is_archived ? templates_shared_index_path : root_path, class: 'flex items-center') do %> + <%= link_to(@is_archived ? templates_shared_index_path : root_path, class: 'flex items-center mb-1 md:mb-0') do %> <%= svg_icon('chevron_left', class: 'w-5 h-5') %> <%= @is_archived ? t('back_to_active') : t('home') %> <% end %>
-
-

+
+

<%= svg_icon('folder', class: 'w-9 h-9 flex-shrink-0') %> <%= t('shared') %> <% if @is_archived %> <%= t('archived') %> <% end %>

-
+
<% if params[:q].present? || @pagy.count.nil? || @pagy.count > 1 %> <%= render 'shared/search_input' %> <% end %> diff --git a/config/environments/production.rb b/config/environments/production.rb index 8a14ab05..1eb97647 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -146,6 +146,8 @@ Rails.application.configure do current_user = controller.instance_variable_get(:@current_user) + os = DetectBrowserDevice.os(controller.request.user_agent) if ENV['MULTITENANT'] == 'true' + { host: controller.request.host, fwd: controller.request.remote_ip, @@ -161,6 +163,7 @@ Rails.application.configure do params[:submit_form_slug] || params[:template_slug]).to_s.first(5) }.compact_blank, + **(os ? { os: } : {}), uid: current_user.try(:id), aid: current_user.try(:account_id), rid: resource.try(:id), diff --git a/config/initializers/rack.rb b/config/initializers/rack.rb new file mode 100644 index 00000000..9a53864a --- /dev/null +++ b/config/initializers/rack.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +Rack::Request.forwarded_priority = %i[x_forwarded] diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 078e4f47..63897f6c 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -930,6 +930,7 @@ en: &en please_verify_your_email_address_to_continue: Please verify your email address to continue. verification_code_sent_click_link_or_enter_code: A verification code has been sent to your email. Click the email link or enter the one time code to confirm your email. use_otp_code_to_verify_email_or_click_link_below_html: 'Use %{code} code to verify your email or click the link below:' + use_otp_code_to_verify_email_html: 'Use %{code} code to verify your email:' verify_your_email: Verify your email your_email_has_been_confirmed: Your email has been confirmed. invalid_or_expired_verification_code: Invalid or expired verification code. @@ -2007,6 +2008,7 @@ es: &es please_verify_your_email_address_to_continue: Por favor, verifica tu dirección de correo electrónico para continuar. verification_code_sent_click_link_or_enter_code: Se ha enviado un código de verificación a tu correo. Puedes hacer clic en el enlace del correo o ingresar el código a continuación. use_otp_code_to_verify_email_or_click_link_below_html: 'Usa el código %{code} para verificar tu correo electrónico o haz clic en el enlace a continuación:' + use_otp_code_to_verify_email_html: 'Usa el código %{code} para verificar tu correo electrónico:' verify_your_email: Verificar tu correo electrónico your_email_has_been_confirmed: Tu correo electrónico ha sido confirmado. invalid_or_expired_verification_code: Código de verificación inválido o expirado. @@ -3084,6 +3086,7 @@ it: &it please_verify_your_email_address_to_continue: Verifica il tuo indirizzo email per continuare. verification_code_sent_click_link_or_enter_code: "È stato inviato un codice di verifica alla tua email. Puoi cliccare il link nell'email o inserire il codice qui sotto." use_otp_code_to_verify_email_or_click_link_below_html: 'Usa il codice %{code} per verificare la tua email o clicca il link qui sotto:' + use_otp_code_to_verify_email_html: 'Usa il codice %{code} per verificare la tua email:' verify_your_email: Verifica la tua email your_email_has_been_confirmed: La tua email è stata confermata. invalid_or_expired_verification_code: Codice di verifica non valido o scaduto. @@ -4158,6 +4161,7 @@ fr: &fr please_verify_your_email_address_to_continue: "Veuillez vérifier votre adresse e-mail pour continuer." verification_code_sent_click_link_or_enter_code: "Un code de vérification a été envoyé à votre adresse e-mail. Vous pouvez cliquer sur le lien dans l'e-mail ou saisir le code ci-dessous." use_otp_code_to_verify_email_or_click_link_below_html: "Utilisez le code %{code} pour vérifier votre e-mail ou cliquez sur le lien ci-dessous :" + use_otp_code_to_verify_email_html: "Utilisez le code %{code} pour vérifier votre e-mail :" verify_your_email: "Vérifier votre e-mail" your_email_has_been_confirmed: "Votre adresse e-mail a été confirmée." invalid_or_expired_verification_code: "Code de vérification invalide ou expiré." @@ -5235,6 +5239,7 @@ pt: &pt please_verify_your_email_address_to_continue: Verifique seu endereço de e-mail para continuar. verification_code_sent_click_link_or_enter_code: Um código de verificação foi enviado para seu e-mail. Você pode clicar no link do e-mail ou inserir o código abaixo. use_otp_code_to_verify_email_or_click_link_below_html: 'Use o código %{code} para verificar seu e-mail ou clique no link abaixo:' + use_otp_code_to_verify_email_html: 'Use o código %{code} para verificar seu e-mail:' verify_your_email: Verificar seu e-mail your_email_has_been_confirmed: Seu e-mail foi confirmado. invalid_or_expired_verification_code: Código de verificação inválido ou expirado. @@ -6312,6 +6317,7 @@ de: &de please_verify_your_email_address_to_continue: Bitte bestätigen Sie Ihre E-Mail-Adresse, um fortzufahren. verification_code_sent_click_link_or_enter_code: Ein Verifizierungscode wurde an Ihre E-Mail gesendet. Sie können auf den Link in der E-Mail klicken oder den Code unten eingeben. use_otp_code_to_verify_email_or_click_link_below_html: 'Verwenden Sie den Code %{code}, um Ihre E-Mail zu bestätigen, oder klicken Sie auf den Link unten:' + use_otp_code_to_verify_email_html: 'Verwenden Sie den Code %{code}, um Ihre E-Mail zu bestätigen:' verify_your_email: E-Mail bestätigen your_email_has_been_confirmed: Ihre E-Mail-Adresse wurde bestätigt. invalid_or_expired_verification_code: Ungültiger oder abgelaufener Verifizierungscode. @@ -7790,6 +7796,7 @@ nl: &nl please_verify_your_email_address_to_continue: Bevestig uw e-mailadres om door te gaan. verification_code_sent_click_link_or_enter_code: Er is een verificatiecode naar uw e-mail gestuurd. U kunt op de link in de e-mail klikken of de code hieronder invoeren. use_otp_code_to_verify_email_or_click_link_below_html: 'Gebruik code %{code} om uw e-mail te verifiëren of klik op de link hieronder:' + use_otp_code_to_verify_email_html: 'Gebruik code %{code} om uw e-mail te verifiëren:' verify_your_email: Uw e-mail verifiëren your_email_has_been_confirmed: Uw e-mailadres is bevestigd. invalid_or_expired_verification_code: Ongeldige of verlopen verificatiecode. diff --git a/lib/detect_browser_device.rb b/lib/detect_browser_device.rb index 0240d278..bebbe39f 100644 --- a/lib/detect_browser_device.rb +++ b/lib/detect_browser_device.rb @@ -26,6 +26,45 @@ module DetectBrowserDevice Silk /ix + WINDOWS_USER_AGENT_REGEXP = / + Windows| + Win64 | + Win32 | + WOW64 + /ix + + ANDROID_USER_AGENT_REGEXP = / + Android| + Silk | + Kindle + /ix + + IOS_USER_AGENT_REGEXP = / + iPhone| + iPad | + iPod | + iOS + /ix + + MACOS_USER_AGENT_REGEXP = / + Macintosh | + Mac\ OS\ X | + MacIntel + /ix + + LINUX_USER_AGENT_REGEXP = / + Linux | + X11 | + CrOS | + Ubuntu | + Fedora | + FreeBSD| + OpenBSD| + NetBSD + /ix + + SDK_USER_AGENT_REGEXP = /\ADocuSeal (?Ruby|Python|PHP|Java|C#|JS|Go|CLI) v/i + def call(user_agent) return if user_agent.blank? @@ -34,4 +73,21 @@ module DetectBrowserDevice 'desktop' end + + def os(user_agent) + return if user_agent.blank? + + sdk = user_agent[SDK_USER_AGENT_REGEXP, :sdk] + + return sdk.downcase if sdk + + case user_agent + when WINDOWS_USER_AGENT_REGEXP then 'windows' + when ANDROID_USER_AGENT_REGEXP then 'android' + when IOS_USER_AGENT_REGEXP then 'ios' + when MACOS_USER_AGENT_REGEXP then 'macos' + when LINUX_USER_AGENT_REGEXP then 'linux' + else 'other' + end + end end diff --git a/lib/leptonica.rb b/lib/leptonica.rb index b18ee82e..bd79de7c 100644 --- a/lib/leptonica.rb +++ b/lib/leptonica.rb @@ -146,10 +146,14 @@ module Leptonica end def build_pix(image) + buffer = image.write_to_memory + + raise LeptonicaError, 'Failed to read image' if buffer.bytesize != image.width * image.height * 4 + pix = checked(pixCreate(image.width, image.height, 32), 'Failed to read image') pixSetSpp(pix, 3) - pixGetData(pix).put_bytes(0, image.write_to_memory) + pixGetData(pix).put_bytes(0, buffer) raise LeptonicaError, 'Failed to read image' unless pixEndianByteSwap(pix).zero? @@ -159,9 +163,10 @@ module Leptonica def load_image(image_data) image = ImageUtils.load_vips(image_data) - image = image.colourspace(:srgb) if image.interpretation != :srgb image = image.cast(:uchar) if image.format != :uchar - image = image.bandjoin(255) unless image.has_alpha? + image = image.colourspace(:srgb) if image.interpretation != :srgb + image = image.extract_band(0, n: 4) if image.bands > 4 + image = image.bandjoin([255] * (4 - image.bands)) if image.bands < 4 image end diff --git a/lib/mcp/handle_request.rb b/lib/mcp/handle_request.rb deleted file mode 100644 index 83d6725f..00000000 --- a/lib/mcp/handle_request.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -module Mcp - module HandleRequest - TOOLS = [ - Mcp::Tools::SearchTemplates, - Mcp::Tools::LoadTemplate, - 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 diff --git a/lib/mcp/tools/create_template.rb b/lib/mcp/tools/create_template.rb deleted file mode 100644 index f19b353e..00000000 --- a/lib/mcp/tools/create_template.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -module Mcp - module Tools - module CreateTemplate - SCHEMA = { - name: 'create_template', - title: 'Create Template', - description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \ - 'to create an empty template and receive an edit URL where the file can be uploaded via the UI.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Template name (used as the template name and required when url is not provided)' - }, - url: { - type: 'string', - description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \ - 'created and the returned edit_url can be used to upload a file via the UI.' - } - }, - required: %w[name] - }, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: true - } - }.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 - - template = Template.new( - account:, - author: current_user, - folder: account.default_template_folder, - source: :mcp, - name: arguments['name'].to_s.presence || 'New Template', - fields: [], - schema: [] - ) - - if 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'])) - - file = ActionDispatch::Http::UploadedFile.new( - tempfile:, - filename:, - type: Marcel::MimeType.for(tempfile) - ) - - template.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:) - else - template.save! - end - - 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 - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength - end - end -end diff --git a/lib/mcp/tools/load_template.rb b/lib/mcp/tools/load_template.rb deleted file mode 100644 index ee52af37..00000000 --- a/lib/mcp/tools/load_template.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -module Mcp - module Tools - module LoadTemplate - SCHEMA = { - name: 'load_template', - title: 'Load Template', - description: 'Load a template with its fields. Each field includes name, type, and the signing role name.', - inputSchema: { - type: 'object', - properties: { - template_id: { - type: 'integer', - description: 'Template identifier' - } - }, - required: %w[template_id] - }, - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false - } - }.freeze - - module_function - - def call(arguments, _current_user, current_ability) - template = Template.accessible_by(current_ability).find_by(id: arguments['template_id']) - - if !template || !current_ability.can?(:read, template) - return { content: [{ type: 'text', text: 'Template not found' }], isError: true } - end - - submitters_index = template.submitters.index_by { |s| s['uuid'] } - - roles = template.submitters.pluck('name') - - fields = template.fields.filter_map do |field| - next if field['name'].blank? - - { - name: field['name'], - type: field['type'], - role: submitters_index[field['submitter_uuid']]&.dig('name') - } - end - - { - content: [ - { - type: 'text', - text: { - id: template.id, - name: template.name, - roles: roles, - fields: fields - }.to_json - } - ] - } - end - end - end -end diff --git a/lib/mcp/tools/search_documents.rb b/lib/mcp/tools/search_documents.rb deleted file mode 100644 index bf1f56f5..00000000 --- a/lib/mcp/tools/search_documents.rb +++ /dev/null @@ -1,65 +0,0 @@ -# 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 diff --git a/lib/mcp/tools/search_templates.rb b/lib/mcp/tools/search_templates.rb deleted file mode 100644 index 9d9bbe52..00000000 --- a/lib/mcp/tools/search_templates.rb +++ /dev/null @@ -1,53 +0,0 @@ -# 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 diff --git a/lib/mcp/tools/send_documents.rb b/lib/mcp/tools/send_documents.rb deleted file mode 100644 index 04894798..00000000 --- a/lib/mcp/tools/send_documents.rb +++ /dev/null @@ -1,140 +0,0 @@ -# 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' - }, - role: { - type: 'string', - description: 'Signing role name from the template' - }, - fields: { - type: 'array', - description: 'Prefill field values for this submitter (fields become readonly)', - items: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Field name' - }, - value: { - description: 'Prefilled value for the field' - } - }, - required: %w[name value] - } - } - } - } - } - }, - required: %w[template_id submitters] - }, - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: true - } - }.freeze - - module_function - - # rubocop:disable Metrics - def call(arguments, current_user, current_ability) - template = Template.accessible_by(current_ability).find_by(id: arguments['template_id']) - - if !template || !current_ability.can?(:read, template) - return { content: [{ type: 'text', text: 'Template not found' }], isError: true } - end - - if template.archived_at? - return { content: [{ type: 'text', text: 'Template has been archived' }], isError: true } - end - - 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| - attrs = s.slice('email', 'name', 'role', 'phone').compact_blank - - fields = Array.wrap(s['fields']).filter_map do |f| - next if f['name'].blank? - - { 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true } - end - - attrs['fields'] = fields if fields.present? - - attrs.with_indifferent_access - end - - submissions = Submissions.create_from_submitters( - template:, - user: current_user, - source: :mcp, - submitters_order: template.preferences['submitters_order'].presence || 'random', - submissions_attrs: { 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) - - 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 - end - end -end diff --git a/spec/factories/template_folders.rb b/spec/factories/template_folders.rb index b7f084d0..f6d26ab3 100644 --- a/spec/factories/template_folders.rb +++ b/spec/factories/template_folders.rb @@ -5,7 +5,7 @@ FactoryBot.define do account author factory: %i[user] - name { Faker::Book.title } + name { Faker::Book.unique.title } trait :with_templates do after(:create) do |template_folder| diff --git a/spec/factories/templates.rb b/spec/factories/templates.rb index 2091821c..33988385 100644 --- a/spec/factories/templates.rb +++ b/spec/factories/templates.rb @@ -5,7 +5,7 @@ FactoryBot.define do account author factory: %i[user] - name { Faker::Book.title } + name { Faker::Book.unique.title } transient do submitter_count { 1 } diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 74284e07..dbaaed37 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -65,6 +65,7 @@ RSpec.configure do |config| config.before do Sidekiq::Worker.clear_all + Faker::UniqueGenerator.clear end config.before do |example|