mirror of https://github.com/docusealco/docuseal
* new controller to handle change requests * add button and modal on completed submission view to request changes * webhook job will send out to external API when submission is updated for changes_requested_at * email will be sent to user that need to make changes * submission status steps back from "completed"pull/544/head
parent
6228b8a037
commit
a6354e6802
@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmittersRequestChangesController < ApplicationController
|
||||
before_action :load_submitter
|
||||
skip_before_action :verify_authenticity_token, only: :request_changes
|
||||
|
||||
def request_changes
|
||||
if request.get?
|
||||
render 'submitters_request_changes/request_changes', layout: false if request.xhr?
|
||||
else
|
||||
return redirect_back(fallback_location: root_path, alert: 'Invalid request') unless can_request_changes?
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
@submitter.update!(
|
||||
changes_requested_at: Time.current,
|
||||
completed_at: nil
|
||||
)
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(
|
||||
@submitter,
|
||||
'request_changes',
|
||||
request,
|
||||
{ reason: params[:reason], requested_by: current_user.id }
|
||||
)
|
||||
end
|
||||
|
||||
if @submitter.email.present?
|
||||
SubmitterMailer.changes_requested_email(@submitter, current_user, params[:reason]).deliver!
|
||||
end
|
||||
|
||||
WebhookUrls.for_account_id(@submitter.account_id, 'form.changes_requested').each do |webhook_url|
|
||||
SendFormChangesRequestedWebhookRequestJob.perform_async(
|
||||
'submitter_id' => @submitter.id,
|
||||
'webhook_url_id' => webhook_url.id
|
||||
)
|
||||
end
|
||||
|
||||
redirect_back(fallback_location: submission_path(@submitter.submission),
|
||||
notice: 'Changes have been requested and the submitter has been notified.')
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:slug])
|
||||
authorize! :read, @submitter
|
||||
end
|
||||
|
||||
def can_request_changes?
|
||||
# Only the user who created the submission can request changes
|
||||
# Only for completed submissions that haven't been declined
|
||||
current_user == @submitter.submission.created_by_user &&
|
||||
@submitter.completed_at? &&
|
||||
!@submitter.declined_at? &&
|
||||
!@submitter.changes_requested_at?
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendFormChangesRequestedWebhookRequestJob
|
||||
include Sidekiq::Job
|
||||
|
||||
sidekiq_options queue: :webhooks
|
||||
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
return if webhook_url.url.blank? || webhook_url.events.exclude?('form.changes_requested')
|
||||
|
||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||
|
||||
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.changes_requested',
|
||||
data: Submitters::SerializeForWebhook.call(submitter))
|
||||
|
||||
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
|
||||
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
|
||||
SendFormChangesRequestedWebhookRequestJob.perform_in((2**attempt).minutes, {
|
||||
'submitter_id' => submitter.id,
|
||||
'webhook_url_id' => webhook_url.id,
|
||||
'attempt' => attempt + 1,
|
||||
'last_status' => resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,10 @@
|
||||
<p>Hello <%= @submitter.name || @submitter.email %>,</p>
|
||||
|
||||
<p><%= [@user.first_name, @user.last_name].join(' ').strip.presence || @user.email %> has requested changes to your submission for "<%= @submission.name || @submission.template.name %>".</p>
|
||||
|
||||
<p><strong>Message from <%= [@user.first_name, @user.last_name].join(' ').strip.presence || @user.email %>:</strong></p>
|
||||
<%= simple_format(h(@reason)) %>
|
||||
|
||||
<p>To make the requested changes, please log in to your account and resubmit the form.</p>
|
||||
|
||||
<p>If you have any questions, please reply to this email.</p>
|
||||
@ -0,0 +1,34 @@
|
||||
<turbo-frame id="modal" target="_top">
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box relative">
|
||||
<label class="btn btn-sm btn-circle absolute right-2 top-2" onclick="document.getElementById('modal').innerHTML = ''">✕</label>
|
||||
<h3 class="text-lg font-bold mb-4">Request Changes</h3>
|
||||
|
||||
<p class="mb-4">
|
||||
Request changes from <strong><%= @submitter.name || @submitter.email %></strong> for this submission.
|
||||
They will receive an email with your message and be able to resubmit the form.
|
||||
</p>
|
||||
|
||||
<%= form_for '', url: request_changes_submitter_path(@submitter.slug), method: :post do |f| %>
|
||||
<div class="form-control mt-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Message (required)</span>
|
||||
</label>
|
||||
<%= f.text_area :reason,
|
||||
required: true,
|
||||
class: 'textarea textarea-bordered w-full',
|
||||
dir: 'auto',
|
||||
placeholder: 'Please provide specific details about what needs to be changed...',
|
||||
rows: '6' %>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<label class="btn btn-ghost" onclick="document.getElementById('modal').innerHTML = ''">Cancel</label>
|
||||
<toggle-submit dir="auto">
|
||||
<%= f.button 'Request Changes', class: 'btn btn-warning' %>
|
||||
</toggle-submit>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</turbo-frame>
|
||||
@ -0,0 +1,5 @@
|
||||
class AddChangesRequestedAtToSubmitters < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :submitters, :changes_requested_at, :datetime
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,61 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SendFormChangesRequestedWebhookRequestJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:template) { create(:template, account: account, author: user) }
|
||||
let(:submission) { create(:submission, template: template, created_by_user: user) }
|
||||
let(:submitter) do
|
||||
create(
|
||||
:submitter, submission: submission, uuid: template.submitters.first['uuid'], changes_requested_at: Time.current
|
||||
)
|
||||
end
|
||||
let(:webhook_url) { create(:webhook_url, account: account, events: ['form.changes_requested']) }
|
||||
|
||||
before do
|
||||
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
|
||||
value: GenerateCertificate.call.transform_values(&:to_pem))
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 200)
|
||||
end
|
||||
|
||||
it 'sends a webhook request' do
|
||||
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).with(
|
||||
body: {
|
||||
'event_type' => 'form.changes_requested',
|
||||
'timestamp' => /.*/,
|
||||
'data' => JSON.parse(Submitters::SerializeForWebhook.call(submitter.reload).to_json)
|
||||
},
|
||||
headers: {
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => 'DocuSeal.com Webhook'
|
||||
}
|
||||
).once
|
||||
end
|
||||
|
||||
it "doesn't send a webhook request if the event is not in the webhook's events" do
|
||||
webhook_url.update!(events: ['form.completed'])
|
||||
|
||||
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
|
||||
|
||||
expect(WebMock).not_to have_requested(:post, webhook_url.url)
|
||||
end
|
||||
|
||||
it 'retries on failure' do
|
||||
stub_request(:post, webhook_url.url).to_return(status: 500)
|
||||
|
||||
expect do
|
||||
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
|
||||
end.to change(described_class.jobs, :size).by(1)
|
||||
|
||||
expect(WebMock).to have_requested(:post, webhook_url.url).once
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SubmitterMailer, type: :mailer do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account, first_name: 'John', last_name: 'Doe') }
|
||||
let(:template) { create(:template, account: account, author: user) }
|
||||
let(:submission) { create(:submission, template: template, account: account, created_by_user: user) }
|
||||
let(:submitter) do
|
||||
create(:submitter, submission: submission, account: account, email: 'test@example.com', name: 'Jane Smith')
|
||||
end
|
||||
|
||||
describe '#changes_requested_email' do
|
||||
let(:reason) { 'Please fix the signature field' }
|
||||
let(:mail) { described_class.changes_requested_email(submitter, user, reason) }
|
||||
|
||||
it 'sets the correct email attributes' do
|
||||
expect(mail.to).to eq(['test@example.com'])
|
||||
expect(mail.subject).to include('Changes requested')
|
||||
expect(mail.from).to be_present
|
||||
end
|
||||
|
||||
it 'includes the reason in the email body' do
|
||||
expect(mail.body.encoded).to include(reason)
|
||||
end
|
||||
|
||||
it 'includes the user name in the email body' do
|
||||
expect(mail.body.encoded).to include('John Doe')
|
||||
end
|
||||
|
||||
it 'includes the submitter name in the greeting' do
|
||||
expect(mail.body.encoded).to include('Jane Smith')
|
||||
end
|
||||
|
||||
it 'includes resubmit instructions' do
|
||||
expect(mail.body.encoded).to include('resubmit')
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SubmittersRequestChangesController, type: :controller do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:template) { create(:template, account: account, author: user) }
|
||||
let(:submission) { create(:submission, template: template, account: account, created_by_user: user) }
|
||||
let(:submitter) { create(:submitter, submission: submission, account: account, completed_at: 1.hour.ago) }
|
||||
|
||||
before do
|
||||
sign_in user
|
||||
end
|
||||
|
||||
describe 'GET #request_changes' do
|
||||
it 'renders the request changes modal' do
|
||||
get :request_changes, params: { slug: submitter.slug }, xhr: true
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST #request_changes' do
|
||||
context 'when user can request changes' do
|
||||
it 'updates submitter and sends notifications' do
|
||||
expect do
|
||||
post :request_changes, params: { slug: submitter.slug, reason: 'Please fix the signature' }
|
||||
end.to change { submitter.reload.changes_requested_at }.from(nil)
|
||||
.and change { submitter.reload.completed_at }.to(nil)
|
||||
|
||||
expect(response).to redirect_to(submission_path(submission))
|
||||
end
|
||||
|
||||
it 'creates submission event' do
|
||||
expect do
|
||||
post :request_changes, params: { slug: submitter.slug, reason: 'Fix this' }
|
||||
end.to change(SubmissionEvent, :count).by(1)
|
||||
|
||||
event = SubmissionEvent.last
|
||||
expect(event.event_type).to eq('request_changes')
|
||||
expect(event.data['reason']).to eq('Fix this')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user cannot request changes' do
|
||||
let(:other_user) { create(:user, account: account) }
|
||||
|
||||
before { sign_in other_user }
|
||||
|
||||
it 'redirects with alert' do
|
||||
post :request_changes, params: { slug: submitter.slug, reason: 'Fix this' }
|
||||
expect(response).to redirect_to(root_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in new issue