diff --git a/app/controllers/api/submissions_void_controller.rb b/app/controllers/api/submissions_void_controller.rb new file mode 100644 index 00000000..759a93da --- /dev/null +++ b/app/controllers/api/submissions_void_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Api + class SubmissionsVoidController < ApiBaseController + load_and_authorize_resource :submission + + before_action only: :create do + authorize!(:destroy, @submission) + end + + def create + Submissions::Void.call(@submission, user: current_user, reason: params[:reason], request:) + + render json: { + id: @submission.id, + status: 'voided', + voided_at: @submission.voided_at, + void_reason: @submission.void_reason + } + rescue Submissions::Void::ReasonRequiredError, Submissions::Void::NotVoidableError => e + render json: { error: e.message }, status: :unprocessable_content + end + end +end diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index ed9c2629..29bfadd4 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -128,7 +128,8 @@ class StartFormController < ApplicationController submitter ||= Submitter .where(submission: template.submissions.where(expire_at: Time.current..) - .or(template.submissions.where(expire_at: nil)).where(archived_at: nil)) + .or(template.submissions.where(expire_at: nil)) + .where(archived_at: nil, voided_at: nil)) .order(id: :desc) .where(declined_at: nil) .where(external_id: nil) diff --git a/app/controllers/submissions_unarchive_controller.rb b/app/controllers/submissions_unarchive_controller.rb index 5a60a60b..e060288b 100644 --- a/app/controllers/submissions_unarchive_controller.rb +++ b/app/controllers/submissions_unarchive_controller.rb @@ -4,6 +4,11 @@ class SubmissionsUnarchiveController < ApplicationController load_and_authorize_resource :submission def create + if @submission.voided_at? + return redirect_to submission_path(@submission), + alert: I18n.t('voided_submission_cannot_be_unarchived') + end + @submission.update!(archived_at: nil) redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived') diff --git a/app/controllers/submissions_void_controller.rb b/app/controllers/submissions_void_controller.rb new file mode 100644 index 00000000..51a8878a --- /dev/null +++ b/app/controllers/submissions_void_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class SubmissionsVoidController < ApplicationController + load_and_authorize_resource :submission + + before_action only: %i[new create] do + authorize!(:destroy, @submission) + end + + def new + render layout: false + end + + def create + Submissions::Void.call(@submission, user: current_user, reason: params[:reason], request:) + + redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_voided') + rescue Submissions::Void::ReasonRequiredError, Submissions::Void::NotVoidableError => e + redirect_to submission_path(@submission), alert: e.message + end +end diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index a65c2237..1d5d9165 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -58,6 +58,10 @@ class SubmitFormController < ApplicationController return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_content end + if @submitter.submission.voided_at? + return render json: { error: I18n.t('form_has_been_voided') }, status: :unprocessable_content + end + if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content end @@ -109,6 +113,7 @@ class SubmitFormController < ApplicationController end def maybe_render_locked_page + return render :voided if @submitter.submission.voided_at? return render :archived if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? || @submitter.account.archived_at? diff --git a/app/controllers/submit_form_decline_controller.rb b/app/controllers/submit_form_decline_controller.rb index a55590f1..6c935440 100644 --- a/app/controllers/submit_form_decline_controller.rb +++ b/app/controllers/submit_form_decline_controller.rb @@ -9,6 +9,7 @@ class SubmitFormDeclineController < ApplicationController def create return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? || @submitter.completed_at? || + @submitter.submission.voided_at? || @submitter.submission.archived_at? || @submitter.submission.expired? || @submitter.submission.template&.archived_at? || diff --git a/app/jobs/generate_voided_documents_job.rb b/app/jobs/generate_voided_documents_job.rb new file mode 100644 index 00000000..b0f02ca2 --- /dev/null +++ b/app/jobs/generate_voided_documents_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class GenerateVoidedDocumentsJob + include Sidekiq::Job + + sidekiq_options queue: :default + + def perform(params = {}) + submission = Submission.find_by(id: params['submission_id']) + + return unless submission&.voided_at? + + Submissions::GenerateVoidedDocuments.call(submission) + end +end diff --git a/app/jobs/send_submission_voided_webhook_request_job.rb b/app/jobs/send_submission_voided_webhook_request_job.rb new file mode 100644 index 00000000..f68d91fa --- /dev/null +++ b/app/jobs/send_submission_voided_webhook_request_job.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class SendSubmissionVoidedWebhookRequestJob + include Sidekiq::Job + + sidekiq_options queue: :webhooks + + MAX_ATTEMPTS = 10 + + def perform(params = {}) + submission = Submission.find_by(id: params['submission_id']) + + return unless submission + + webhook_url = WebhookUrl.find_by(id: params['webhook_url_id']) + + return unless webhook_url + + attempt = params['attempt'].to_i + + return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.voided') + + payload = submission.as_json(only: %i[id voided_at]).merge( + 'reason' => submission.void_reason, + 'voided_by_user_id' => submission.void_event&.data&.dig('voided_by_user_id') + ) + + resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.voided', + event_uuid: params['event_uuid'], + record: submission, + attempt:, + data: payload) + + if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && + (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) + SendSubmissionVoidedWebhookRequestJob.perform_in((2**attempt).minutes, { + **params, + 'attempt' => attempt + 1, + 'last_status' => resp&.status.to_i + }) + end + end +end diff --git a/app/mailers/submitter_mailer.rb b/app/mailers/submitter_mailer.rb index 6736c21d..828842ca 100644 --- a/app/mailers/submitter_mailer.rb +++ b/app/mailers/submitter_mailer.rb @@ -102,6 +102,28 @@ class SubmitterMailer < ApplicationMailer end end + def voided_email(submitter, user) + @current_account = submitter.submission.account + @submitter = submitter + @submission = submitter.submission + @user = user + @reason = @submission.void_reason + + reply_to = build_submitter_reply_to(@submitter) + + assign_message_metadata('submitter_voided', @submitter) + + maybe_set_custom_domain(@submitter) + + I18n.with_locale(@current_account.locale) do + mail(from: from_address_for_submitter(submitter), + to: @submitter.friendly_name, + reply_to:, + subject: I18n.t(:name_voided_by_sender, + name: (@submission.name || @submission.template.name).truncate(40))) + end + end + def documents_copy_email(submitter, to: nil, sig: false) @current_account = submitter.submission.account @submitter = submitter diff --git a/app/models/submission.rb b/app/models/submission.rb index 92101b02..a3e63258 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -17,6 +17,7 @@ # template_submitters :text # variables :text # variables_schema :text +# voided_at :datetime # created_at :datetime not null # updated_at :datetime not null # account_id :bigint not null @@ -28,6 +29,7 @@ # index_submissions_on_account_id_and_id (account_id,id) # index_submissions_on_account_id_and_template_id_and_id (account_id,template_id,id) WHERE (archived_at IS NULL) # index_submissions_on_account_id_and_template_id_and_id_archived (account_id,template_id,id) WHERE (archived_at IS NOT NULL) +# index_submissions_on_account_id_and_template_id_and_id_voided (account_id,template_id,id) WHERE (voided_at IS NOT NULL) # index_submissions_on_created_by_user_id (created_by_user_id) # index_submissions_on_slug (slug) UNIQUE # index_submissions_on_template_id (template_id) @@ -68,6 +70,7 @@ class Submission < ApplicationRecord has_many_attached :preview_documents has_many_attached :documents + has_many_attached :voided_documents has_many :template_accesses, primary_key: :template_id, foreign_key: :template_id, dependent: nil, inverse_of: false @@ -86,12 +89,14 @@ class Submission < ApplicationRecord has_many :template_schema_dynamic_document_attachments, through: :template_schema_dynamic_document_versions, source: :document_attachment - scope :active, -> { where(archived_at: nil) } + scope :active, -> { where(archived_at: nil, voided_at: nil) } scope :archived, -> { where.not(archived_at: nil) } + scope :voided, -> { where.not(voided_at: nil) } scope :pending, lambda { - where(expire_at: nil).or(where(expire_at: Time.current..)) - .where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]) - .and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists) + where(voided_at: nil) + .where(expire_at: nil).or(where(voided_at: nil, expire_at: Time.current..)) + .where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]) + .and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists) } scope :completed, lambda { where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]) @@ -125,6 +130,29 @@ class Submission < ApplicationRecord expire_at && expire_at <= Time.current end + def voided? + voided_at.present? + end + + def voidable? + !voided? && !submitters.all?(&:completed_at?) + end + + def void_event + submission_events.find_by(event_type: :void_submission) + end + + def void_reason + void_event&.data&.dig('reason') + end + + def voided_by_user + return unless voided? + + user_id = void_event&.data&.dig('voided_by_user_id') + user_id && User.find_by(id: user_id) + end + def schema_documents return documents_attachments unless template_id? diff --git a/app/models/submission_event.rb b/app/models/submission_event.rb index e88d192f..1686a7e6 100644 --- a/app/models/submission_event.rb +++ b/app/models/submission_event.rb @@ -65,16 +65,17 @@ class SubmissionEvent < ApplicationRecord complete_form: 'complete_form', decline_form: 'decline_form', delegate_form: 'delegate_form', - api_complete_form: 'api_complete_form' + api_complete_form: 'api_complete_form', + void_submission: 'void_submission' }, scope: false private def set_submission_id - self.submission_id = submitter&.submission_id + self.submission_id ||= submitter&.submission_id end def set_account_id - self.account_id = submitter&.account_id + self.account_id ||= submitter&.account_id || submission&.account_id end end diff --git a/app/models/submitter.rb b/app/models/submitter.rb index 0e4bc6c2..2f3ba3d4 100644 --- a/app/models/submitter.rb +++ b/app/models/submitter.rb @@ -71,7 +71,9 @@ class Submitter < ApplicationRecord after_destroy :anonymize_email_events, if: -> { Docuseal.multitenant? } def status - if declined_at? + if submission.voided_at? + 'voided' + elsif declined_at? 'declined' elsif completed_at? 'completed' @@ -105,7 +107,7 @@ class Submitter < ApplicationRecord end def status_event_at - declined_at || completed_at || opened_at || sent_at || created_at + submission.voided_at || declined_at || completed_at || opened_at || sent_at || created_at end def with_signature_fields? diff --git a/app/models/webhook_url.rb b/app/models/webhook_url.rb index 7c7b48cc..ce554337 100644 --- a/app/models/webhook_url.rb +++ b/app/models/webhook_url.rb @@ -33,6 +33,7 @@ class WebhookUrl < ApplicationRecord submission.completed submission.expired submission.archived + submission.voided template.created template.updated template.archived diff --git a/app/views/submissions/show.html.erb b/app/views/submissions/show.html.erb index 83187f47..1a9d5d7e 100644 --- a/app/views/submissions/show.html.erb +++ b/app/views/submissions/show.html.erb @@ -9,6 +9,44 @@ <% with_timestamp_seconds = configs.find { |e| e.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true %> <% with_signature_id_reason = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false %>
+ <% if @submission.voided_at? %> +
+
+ <%= svg_icon('x_circle', class: 'w-6 h-6 text-error flex-shrink-0 mt-0.5') %> +
+
+ <%= t('voided') %> +
+
+ <%= t('form_has_been_voided_on_html', time: l(@submission.voided_at.in_time_zone(@submission.account.timezone), format: :long, locale: @submission.account.locale)).html_safe %> + <% if (voided_by = @submission.voided_by_user) %> + — <%= t('voided_by_name', name: voided_by.full_name.presence || voided_by.email) %> + <% end %> +
+ <% if (reason = @submission.void_reason).present? %> +
+ <%= t('reason') %>: + <%= reason %> +
+ <% end %> + <% if @submission.voided_documents.attached? %> +
+ <% @submission.voided_documents.attachments.each do |att| %> + + <%= svg_icon('download', class: 'w-4 h-4') %> + <%= att.filename.base %> + + <% end %> +
+ <% else %> +
+ <%= t('voided_watermark_pending') %> +
+ <% end %> +
+
+
+ <% end %>
<%= render 'submissions/logo' %> @@ -16,9 +54,15 @@
<% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %> - <% if signed_in? && can?(:create, @submission) && @submission.archived_at? && !is_all_completed %> + <% if signed_in? && can?(:create, @submission) && @submission.archived_at? && !@submission.voided_at? && !is_all_completed %> <%= button_to button_title(title: t('unarchive'), disabled_with: t('unarchive')[0..-2], icon: svg_icon('rotate', class: 'w-6 h-6')), submission_unarchive_index_path(@submission), class: 'btn btn-primary btn-ghost text-base hidden md:flex' %> <% end %> + <% if signed_in? && can?(:destroy, @submission) && @submission.voidable? %> + <%= link_to new_submission_void_path(@submission), class: 'white-button !text-error', data: { turbo_frame: :modal } do %> + <%= svg_icon('x_circle', class: 'w-6 h-6') %> + + <% end %> + <% end %> <% if @submission.audit_trail.present? %> <%= svg_icon('external_link', class: 'w-6 h-6') %> @@ -68,7 +112,7 @@ <% end %>
<% end %> - <% elsif @submission.submitters.to_a.size == 1 && !@submission.expired? && !@submission.submitters.to_a.first.declined_at? && !@submission.archived_at? && !@submission.template&.archived_at? %> + <% elsif @submission.submitters.to_a.size == 1 && !@submission.expired? && !@submission.submitters.to_a.first.declined_at? && !@submission.archived_at? && !@submission.voided_at? && !@submission.template&.archived_at? %> <%= render 'shared/clipboard_copy', text: submit_form_url(slug: @submission.submitters.to_a.first.slug, host: form_link_host), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy_share_link'), copied_title: t('copied_to_clipboard') %> <% end %>
@@ -159,7 +203,7 @@ <%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %> - <% if signed_in? && can?(:update, @submission) && submitter && !submitter.completed_at? && !submitter.declined_at? && !@submission.archived_at? && !@submission.template&.archived_at? && !@submission.expired? && !submitter.start_form_submission_events.any? %> + <% if signed_in? && can?(:update, @submission) && submitter && !submitter.completed_at? && !submitter.declined_at? && !@submission.archived_at? && !@submission.voided_at? && !@submission.template&.archived_at? && !@submission.expired? && !submitter.start_form_submission_events.any? %> <%= link_to edit_submitter_path(submitter), class: 'shrink-0 inline md:hidden md:group-hover:inline', data: { turbo_frame: 'modal' } do %> <%= svg_icon('pencil', class: 'w-5 h-5') %> @@ -198,7 +242,9 @@ <%= svg_icon('writing', class: 'w-5 h-5') %> <% end %> - <% if submitter&.declined_at? %> + <% if @submission.voided_at? %> + <%= t('voided_on_time', time: l(@submission.voided_at.in_time_zone(@submission.account.timezone), format: :short, locale: @submission.account.locale)) %> + <% elsif submitter&.declined_at? %> <%= t('declined_on_time', time: l(submitter.declined_at.in_time_zone(@submission.account.timezone), format: :short, locale: @submission.account.locale)) %> <% elsif submitter %> <% if submitter.completed_at? %> @@ -225,15 +271,15 @@ <% end %> - <% if signed_in? && submitter && submitter.email && !submitter.completed_at && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:update, @submission) && Accounts.can_send_emails?(current_account) && !@submission.expired? && !submitter.declined_at? %> + <% if signed_in? && submitter && submitter.email && !submitter.completed_at && !@submission.archived_at? && !@submission.voided_at? && !@submission.template&.archived_at? && can?(:update, @submission) && Accounts.can_send_emails?(current_account) && !@submission.expired? && !submitter.declined_at? %>
<%= button_to button_title(title: submitter.sent_at? ? t('re_send_email') : t('send_email'), disabled_with: t('sending')), submitter_send_email_index_path(submitter), class: 'btn btn-sm btn-primary w-full' %>
<% end %> - <% if signed_in? && submitter && submitter.phone && !submitter.completed_at && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:update, @submission) && !@submission.expired? && !submitter.declined_at? %> + <% if signed_in? && submitter && submitter.phone && !submitter.completed_at && !@submission.archived_at? && !@submission.voided_at? && !@submission.template&.archived_at? && can?(:update, @submission) && !@submission.expired? && !submitter.declined_at? %> <%= render 'submissions/send_sms_button', submitter: %> <% end %> - <% if signed_in? && submitter && !submitter.completed_at? && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:create, @submission) && !@submission.expired? && !submitter.declined_at? %> + <% if signed_in? && submitter && !submitter.completed_at? && !@submission.archived_at? && !@submission.voided_at? && !@submission.template&.archived_at? && can?(:create, @submission) && !@submission.expired? && !submitter.declined_at? %>
<%= t('sign_in_person') %> diff --git a/app/views/submissions_void/new.html.erb b/app/views/submissions_void/new.html.erb new file mode 100644 index 00000000..a7adbbc9 --- /dev/null +++ b/app/views/submissions_void/new.html.erb @@ -0,0 +1,17 @@ +<%= render 'shared/turbo_modal', title: t('void_submission') do %> +
+

+ <%= t('void_submission_confirmation') %> +

+ <%= form_with url: submission_void_index_path(@submission), method: :post, data: { turbo: false } do |f| %> +
+ + <%= f.text_area :reason, required: true, class: 'base-input w-full py-2', dir: 'auto', placeholder: t('provide_a_reason'), rows: 5 %> +
+
+ + <%= f.button button_title(title: t('void'), disabled_with: t('void')), class: 'btn btn-error' %> +
+ <% end %> +
+<% end %> diff --git a/app/views/submit_form/voided.html.erb b/app/views/submit_form/voided.html.erb new file mode 100644 index 00000000..ba9dcf40 --- /dev/null +++ b/app/views/submit_form/voided.html.erb @@ -0,0 +1,28 @@ +
+
+
+
+ <%= render 'start_form/banner' %> +
+
+
+
+ <%= svg_icon('x_circle', class: 'w-10 h-10') %> +
+
+

<%= @submitter.submission.name || @submitter.submission.template.name %>

+

<%= t('form_has_been_voided_on_html', time: l(@submitter.submission.voided_at, format: :long)) %>

+ <% if (voided_by = @submitter.submission.voided_by_user) %> +

<%= t('voided_by_name', name: voided_by.full_name.presence || voided_by.email) %>

+ <% end %> + <% if (reason = @submitter.submission.void_reason).present? %> +

<%= t('reason') %>:

+

<%= reason %>

+ <% end %> +
+
+
+
+
+
+<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %> diff --git a/app/views/submitter_mailer/voided_email.html.erb b/app/views/submitter_mailer/voided_email.html.erb new file mode 100644 index 00000000..32edbf83 --- /dev/null +++ b/app/views/submitter_mailer/voided_email.html.erb @@ -0,0 +1,7 @@ +

<%= t('hi_there') %>,

+

<%= t('name_has_been_voided_with_reason_html', name: @submission.name || @submission.template.name, sender: @user&.full_name.presence || @user&.email || @current_account.name) %>

+<% if @reason.present? %> +

<%= t('reason') %>:

+ <%= simple_format(h(@reason)) %> +<% end %> +

<%= t('voided_email_no_action_required') %>

diff --git a/app/views/templates/_submission.html.erb b/app/views/templates/_submission.html.erb index 35e45851..297e0de6 100644 --- a/app/views/templates/_submission.html.erb +++ b/app/views/templates/_submission.html.erb @@ -1,4 +1,4 @@ -<% status_badges = { 'awaiting' => 'badge-info', 'sent' => 'badge-info', 'completed' => 'badge-success', 'opened' => 'badge-warning', 'declined' => 'badge-error' } %> +<% status_badges = { 'awaiting' => 'badge-info', 'sent' => 'badge-info', 'completed' => 'badge-success', 'opened' => 'badge-warning', 'declined' => 'badge-error', 'voided' => 'badge-error' } %>
<% if local_assigns[:with_template] %> <% template = submission.template %> @@ -34,7 +34,13 @@ <% submitter = submitters.first %>
- <% if submission.expired? && !submitter.completed_at? && !submitter.declined_at? %> + <% if submission.voided_at? %> + + + <%= t('voided') %> + + + <% elsif submission.expired? && !submitter.completed_at? && !submitter.declined_at? %>
<%= t('expired') %> @@ -51,7 +57,7 @@ <%= submitter.name || submitter.email || submitter.phone %> - <% if can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %> + <% if can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.voided_at? && !submission.expired? && !submitter.declined_at? %> <%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %> <%= svg_icon('pencil', class: 'w-5 h-5') %> @@ -78,7 +84,7 @@
- <% elsif !submission.archived_at? && !template&.archived_at? && !submission.expired? && !submitter.declined_at? %> + <% elsif !submission.archived_at? && !submission.voided_at? && !template&.archived_at? && !submission.expired? && !submitter.declined_at? %> <% if current_user.email == submitter.email %> - <% if !submission.archived_at? && !template&.archived_at? && can?(:destroy, submission) %> + <% if !submission.archived_at? && !submission.voided_at? && !template&.archived_at? && can?(:destroy, submission) %> <%= button_to button_title(title: nil, disabled_with: nil, icon: svg_icon('archive', class: 'w-6 h-6'), icon_disabled: svg_icon('loader', class: 'w-6 h-6 animate-spin')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %> @@ -115,7 +121,13 @@ <% else %>
- <% if is_submission_completed %> + <% if submission.voided_at? %> + + + <%= t('voided') %> + + + <% elsif is_submission_completed %> <% latest_submitter = submitters.select(&:completed_at?).max_by(&:completed_at) %> @@ -133,7 +145,7 @@ <% submitters.each_with_index do |submitter, index| %>
- <% if !is_submission_completed && !submission.expired? %> + <% if !is_submission_completed && !submission.expired? && !submission.voided_at? %> <%= t(submitter.status) %> @@ -144,7 +156,7 @@ <%= submitter.name || submitter.email || submitter.phone %> - <% if can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %> + <% if can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.voided_at? && !submission.expired? && !submitter.declined_at? %> <%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %> <%= svg_icon('pencil', class: 'w-5 h-5') %> @@ -164,7 +176,7 @@ <%= t('download')[..-2] %>... - <% elsif !template&.archived_at? && !submission.archived_at? && !is_submission_completed && !submission.expired? && !submitter.declined_at? %> + <% elsif !template&.archived_at? && !submission.archived_at? && !submission.voided_at? && !is_submission_completed && !submission.expired? && !submitter.declined_at? %>
<% if current_user.email == submitter.email %> @@ -208,7 +220,7 @@ <%= t('view') %>
- <% if !submission.archived_at? && !template&.archived_at? %> + <% if !submission.archived_at? && !submission.voided_at? && !template&.archived_at? %> <%= button_to button_title(title: nil, disabled_with: nil, icon: svg_icon('archive', class: 'w-6 h-6'), icon_disabled: svg_icon('loader', class: 'w-6 h-6 animate-spin')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %> diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 055aa022..7963186c 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -564,10 +564,28 @@ en: &en new_recipients_have_been_added: New recipients have been added. submission_has_been_removed: Submission has been removed. submission_has_been_archived: Submission has been archived. + submission_has_been_voided: Submission has been voided. + submission_cannot_be_voided: This submission cannot be voided. + voided_submission_cannot_be_unarchived: A voided submission cannot be unarchived. + void_reason_is_required: A reason is required to void this submission. form_has_been_completed_already: Form has been completed already. form_has_been_archived: Form has been archived. form_has_been_expired: Form has been expired. form_has_been_declined: Form has been declined. + form_has_been_voided: Form has been voided. + form_has_been_voided_on_html: 'Form has been voided on %{time}.' + voided_by_name: 'Voided by %{name}' + void: Void + voided: Voided + void_submission: Void submission + void_submission_confirmation: Voiding stops all signing on this document. The recipients will be notified with the reason you provide. This cannot be undone. + enter_reason_for_voiding: Enter a reason for voiding this document + cancel: Cancel + name_voided_by_sender: '"%{name}" has been voided' + name_has_been_voided_with_reason_html: '"%{name}" has been voided by %{sender}.' + voided_email_no_action_required: No further action is required from you. If you have any questions, please reply to this email. + voided_watermark_pending: Voided document with watermark is being generated… + voided_on_time: 'Voided on %{time}' file_is_missing: File is missing folder_name_has_been_updated: Folder name has been updated. unable_to_rename_folder: Unable to rename folder. diff --git a/config/routes.rb b/config/routes.rb index 3ae4c1b3..566bd91d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,6 +31,7 @@ Rails.application.routes.draw do resources :submitters, only: %i[index show update] resources :submissions, only: %i[index show create destroy] do resources :documents, only: %i[index], controller: 'submission_documents' + resources :void, only: %i[create], controller: 'submissions_void' collection do resources :init, only: %i[create], controller: 'submissions' resources :emails, only: %i[create], controller: 'submissions', as: :submissions_emails @@ -70,6 +71,7 @@ Rails.application.routes.draw do resources :submissions, only: %i[index], controller: 'submissions_dashboard' resources :submissions, only: %i[show destroy] do resources :unarchive, only: %i[create], controller: 'submissions_unarchive' + resources :void, only: %i[new create], controller: 'submissions_void' resources :events, only: %i[index], controller: 'submission_events' resources :download, only: %i[index], controller: 'submissions_download' end diff --git a/db/migrate/20260508120000_add_voided_at_to_submissions.rb b/db/migrate/20260508120000_add_voided_at_to_submissions.rb new file mode 100644 index 00000000..848c3f49 --- /dev/null +++ b/db/migrate/20260508120000_add_voided_at_to_submissions.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddVoidedAtToSubmissions < ActiveRecord::Migration[8.1] + def change + add_column :submissions, :voided_at, :datetime + + add_index :submissions, + %i[account_id template_id id], + where: 'voided_at IS NOT NULL', + name: :index_submissions_on_account_id_and_template_id_and_id_voided + end +end diff --git a/lib/submissions/filter.rb b/lib/submissions/filter.rb index a6677bf4..79f15761 100644 --- a/lib/submissions/filter.rb +++ b/lib/submissions/filter.rb @@ -51,6 +51,8 @@ module Submissions submissions.declined when 'expired' submissions.expired + when 'voided' + submissions.voided when 'sent' submissions.joins(:submitters) .where(submitters: { opened_at: nil, completed_at: nil, declined_at: nil }) diff --git a/lib/submissions/generate_voided_documents.rb b/lib/submissions/generate_voided_documents.rb new file mode 100644 index 00000000..792f461c --- /dev/null +++ b/lib/submissions/generate_voided_documents.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +module Submissions + module GenerateVoidedDocuments + WATERMARK_COLOR = [0.85, 0.10, 0.10].freeze + WATERMARK_STROKE_OPACITY = 0.85 + WATERMARK_FONT_SIZE = 150 + WATERMARK_STROKE_WIDTH = 2.2 + FOOTER_FONT_SIZE = 10 + FOOTER_HEIGHT = 36 + + module_function + + def call(submission) + return [] unless submission.voided_at? + + submission.with_lock do + source_documents = source_attachments(submission) + .select { |a| a.blob.content_type == 'application/pdf' } + .uniq { |a| a.blob.checksum } + existing = submission.voided_documents.attachments.reload + + return existing.to_a if existing.size == source_documents.size && existing.any? + + submission.voided_documents.purge if existing.any? + + voided_by = submission.voided_by_user + reason = submission.void_reason + voided_at = submission.voided_at + + source_documents.each do |source| + watermarked_io = stamp_pdf(source.download, voided_at:, voided_by:, reason:) + + submission.voided_documents.attach( + io: watermarked_io, + filename: build_filename(source.filename), + content_type: 'application/pdf' + ) + end + end + + submission.voided_documents.attachments.reload.to_a + end + + def source_attachments(submission) + if submission.documents.attached? + submission.documents.attachments + elsif submission.template + submission.template.documents_attachments.to_a + else + [] + end + end + + def stamp_pdf(pdf_data, voided_at:, voided_by:, reason:) + doc = HexaPDF::Document.new(io: StringIO.new(pdf_data)) + + doc.pages.each do |page| + draw_watermark_on_page(doc, page) + draw_footer_on_page(doc, page, voided_at:, voided_by:, reason:) + end + + out = StringIO.new + doc.write(out, validate: false) + out.tap(&:rewind) + end + + def draw_watermark_on_page(doc, page) + box = page.box(:media) + width = box.width + height = box.height + + font = doc.fonts.add('Helvetica', variant: :bold) + fragment = HexaPDF::Layout::TextFragment.create( + 'VOIDED', + font:, + font_size: WATERMARK_FONT_SIZE, + stroke_color: WATERMARK_COLOR, + stroke_width: WATERMARK_STROKE_WIDTH, + text_rendering_mode: :stroke, + stroke_alpha: WATERMARK_STROKE_OPACITY + ) + text_width = fragment.width + text_height = fragment.height + + canvas = page.canvas(type: :overlay) + + canvas.save_graphics_state do + center_x = width / 2.0 + center_y = height / 2.0 + angle_rad = Math::PI / 6 + + cos_a = Math.cos(angle_rad) + sin_a = Math.sin(angle_rad) + + offset_x = -(text_width / 2.0) * cos_a + (text_height / 2.0) * sin_a + offset_y = -(text_width / 2.0) * sin_a - (text_height / 2.0) * cos_a + + canvas.transform(cos_a, sin_a, -sin_a, cos_a, center_x + offset_x, center_y + offset_y) + fragment.draw(canvas, 0, 0) + end + end + + def draw_footer_on_page(doc, page, voided_at:, voided_by:, reason:) + box = page.box(:media) + width = box.width + + canvas = page.canvas(type: :overlay) + + canvas.save_graphics_state do + canvas.fill_color(0.95, 0.20, 0.20) + canvas.rectangle(0, 0, width, FOOTER_HEIGHT).fill + end + + font = doc.fonts.add('Helvetica', variant: :bold) + white = [1.0, 1.0, 1.0] + + line1 = build_footer_line1(voided_at, voided_by) + line2 = build_footer_line2(reason) + + [line1, line2].each_with_index do |text, idx| + next if text.blank? + + fragment = HexaPDF::Layout::TextFragment.create(text, font:, font_size: FOOTER_FONT_SIZE, + fill_color: white) + canvas.save_graphics_state do + fragment.draw(canvas, 12, FOOTER_HEIGHT - 14 - (idx * 14)) + end + end + end + + def build_footer_line1(voided_at, voided_by) + timestamp = voided_at.utc.strftime('%Y-%m-%d %H:%M UTC') + voided_by_label = voided_by ? (voided_by.full_name.presence || voided_by.email) : 'an authorized user' + + "VOIDED on #{timestamp} by #{voided_by_label}" + end + + def build_footer_line2(reason) + return nil if reason.blank? + + "Reason: #{reason.to_s.tr("\r\n", ' ').squeeze(' ').strip[0, 200]}" + end + + def build_filename(original_filename) + base = original_filename.base.to_s + ext = original_filename.extension.presence || 'pdf' + + "#{base}-voided.#{ext}" + end + end +end diff --git a/lib/submissions/serialize_for_api.rb b/lib/submissions/serialize_for_api.rb index 4569e9bb..b6b634c1 100644 --- a/lib/submissions/serialize_for_api.rb +++ b/lib/submissions/serialize_for_api.rb @@ -3,7 +3,7 @@ module Submissions module SerializeForApi SERIALIZE_PARAMS = { - only: %i[id name slug source submitters_order expire_at created_at updated_at archived_at], + only: %i[id name slug source submitters_order expire_at created_at updated_at archived_at voided_at], include: { submitters: { only: %i[id] }, template: { only: %i[id name external_id created_at updated_at], @@ -37,7 +37,19 @@ module Submissions json['fields'] = submission.template_fields || submission.template&.fields end - if submitters.all?(&:completed_at?) + if submission.voided_at? + if with_documents + voided_attachments = submission.voided_documents.attachments.to_a + json['documents'] = voided_attachments.map do |att| + { name: att.filename.base, url: ActiveStorage::Blob.proxy_url(att.blob, expires_at:) } + end + end + json['audit_log_url'] = nil + json['combined_document_url'] = nil + json['status'] = 'voided' + json['completed_at'] = nil + json['void_reason'] = submission.void_reason + elsif submitters.all?(&:completed_at?) last_submitter = submitters.max_by(&:completed_at) if with_documents diff --git a/lib/submissions/void.rb b/lib/submissions/void.rb new file mode 100644 index 00000000..186bd536 --- /dev/null +++ b/lib/submissions/void.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Submissions + module Void + NotVoidableError = Class.new(StandardError) + ReasonRequiredError = Class.new(StandardError) + + module_function + + def call(submission, user:, reason:, request: nil) + reason = reason.to_s.strip + raise ReasonRequiredError, I18n.t('void_reason_is_required') if reason.blank? + raise NotVoidableError, I18n.t('submission_cannot_be_voided') unless submission.voidable? + + ApplicationRecord.transaction do + submission.update!(voided_at: Time.current) + + SubmissionEvent.create!( + submission:, + event_type: :void_submission, + data: { + reason:, + voided_by_user_id: user&.id, + ip: request&.remote_ip, + ua: request&.user_agent + }.compact_blank + ) + end + + notify_submitters(submission, user) + WebhookUrls.enqueue_events(submission, 'submission.voided') + GenerateVoidedDocumentsJob.perform_async('submission_id' => submission.id) + + submission + end + + def notify_submitters(submission, user) + submission.submitters.each do |submitter| + next if submitter.email.blank? + next if submitter.sent_at.blank? + + SubmitterMailer.voided_email(submitter, user).deliver_later! + end + end + end +end diff --git a/lib/webhook_urls.rb b/lib/webhook_urls.rb index 242c274c..d34f4315 100644 --- a/lib/webhook_urls.rb +++ b/lib/webhook_urls.rb @@ -10,6 +10,7 @@ module WebhookUrls 'submission.completed' => SendSubmissionCompletedWebhookRequestJob, 'submission.expired' => SendSubmissionExpiredWebhookRequestJob, 'submission.archived' => SendSubmissionArchivedWebhookRequestJob, + 'submission.voided' => SendSubmissionVoidedWebhookRequestJob, 'template.created' => SendTemplateCreatedWebhookRequestJob, 'template.updated' => SendTemplateUpdatedWebhookRequestJob, 'template.archived' => SendTemplateArchivedWebhookRequestJob