mirror of https://github.com/docusealco/docuseal
Merge 14c844813d into 528a1216f8
commit
5084ef922f
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -0,0 +1,17 @@
|
||||
<%= render 'shared/turbo_modal', title: t('void_submission') do %>
|
||||
<div class="px-1 pt-2 pb-1">
|
||||
<p class="text-sm mb-4 text-base-content/80">
|
||||
<%= t('void_submission_confirmation') %>
|
||||
</p>
|
||||
<%= form_with url: submission_void_index_path(@submission), method: :post, data: { turbo: false } do |f| %>
|
||||
<div class="form-control">
|
||||
<label class="label" for="reason"><%= t('enter_reason_for_voiding') %></label>
|
||||
<%= f.text_area :reason, required: true, class: 'base-input w-full py-2', dir: 'auto', placeholder: t('provide_a_reason'), rows: 5 %>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-2 mt-4">
|
||||
<button type="button" class="btn btn-ghost" data-action="click:turbo-modal#close"><%= t('cancel') %></button>
|
||||
<%= f.button button_title(title: t('void'), disabled_with: t('void')), class: 'btn btn-error' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@ -0,0 +1,28 @@
|
||||
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
|
||||
<div class="space-y-6 mx-auto">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-center">
|
||||
<%= render 'start_form/banner' %>
|
||||
</div>
|
||||
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
|
||||
<div class="flex items-center">
|
||||
<div class="mr-3">
|
||||
<%= svg_icon('x_circle', class: 'w-10 h-10') %>
|
||||
</div>
|
||||
<div dir="auto">
|
||||
<p class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template.name %></p>
|
||||
<p class="text-sm"><%= t('form_has_been_voided_on_html', time: l(@submitter.submission.voided_at, format: :long)) %></p>
|
||||
<% if (voided_by = @submitter.submission.voided_by_user) %>
|
||||
<p class="text-xs mt-1 opacity-70"><%= t('voided_by_name', name: voided_by.full_name.presence || voided_by.email) %></p>
|
||||
<% end %>
|
||||
<% if (reason = @submitter.submission.void_reason).present? %>
|
||||
<p class="text-sm mt-2"><%= t('reason') %>:</p>
|
||||
<p class="text-sm whitespace-pre-wrap"><%= reason %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
|
||||
@ -0,0 +1,7 @@
|
||||
<p><%= t('hi_there') %>,</p>
|
||||
<p><%= t('name_has_been_voided_with_reason_html', name: @submission.name || @submission.template.name, sender: @user&.full_name.presence || @user&.email || @current_account.name) %></p>
|
||||
<% if @reason.present? %>
|
||||
<p><strong><%= t('reason') %>:</strong></p>
|
||||
<%= simple_format(h(@reason)) %>
|
||||
<% end %>
|
||||
<p><%= t('voided_email_no_action_required') %></p>
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
Loading…
Reference in new issue