mirror of https://github.com/docusealco/docuseal
Adds a DocuSign-style void to DocuSeal: a user with destroy permission on a submission can void it as long as it hasn't been fully completed. Voiding requires a reason, halts further signing, notifies every already-invited recipient with the reason, stamps a hollow-outline "VOIDED" watermark on each page of the document, and writes an audit event. Voided is a new terminal status — distinct from archived. ## Schema - submissions.voided_at — nullable timestamp + partial index - New SubmissionEvent.event_type value void_submission. Reason + voided_by_user_id stored in the event's data JSON (mirrors decline_form's reason storage) - SubmissionEvent#set_submission_id and set_account_id changed from unconditional = to ||= so an event can be created with a submission directly (no submitter, since void is account-level) - New WebhookUrl::EVENTS value submission.voided ## Domain - Submission#voided? / #voidable? / #void_event / #void_reason / #voided_by_user - Submission.scope :voided; :active and :pending updated to exclude voided - Submitter#status returns 'voided' (highest priority) when the parent submission is voided. #status_event_at returns voided_at first if set - Submission#has_many_attached :voided_documents — watermarked PDFs ## Service Submissions::Void.call(submission, user:, reason:, request:) validates the reason and voidable? invariant, writes the audit event and timestamp in a transaction, fires submission.voided webhook, notifies every recipient with sent_at set (matches DocuSign — only parties who had received the document are notified; sequential-order submitters who hadn't been reached yet are skipped), and enqueues GenerateVoidedDocumentsJob. Submissions::GenerateVoidedDocuments opens each source PDF with HexaPDF, dedupes by content checksum, and stamps a diagonal red "VOIDED" outline (text rendering mode :stroke, 85% stroke alpha, size 150, line width 2.2) plus a footer bar with date / voider / reason on every page. Uses submission.with_lock + a count-based short-circuit so a Sidekiq retry can't produce duplicate attachments. ## Routing - POST /api/submissions/:submission_id/void — JSON - POST /submissions/:submission_id/void — admin form - GET /submissions/:submission_id/void/new — modal reason form start_form_controller, submit_form_controller, and submit_form_decline_controller reject voided submissions on every signing path so a stale email link can't sneak through. SubmissionsUnarchiveController refuses to unarchive a voided submission. ## UI - Submission detail page: red banner with reason / voider / timestamp + download list for the watermarked PDFs. Red Void button in the header opens a turbo-modal that requires a reason. Send- email / send-SMS / sign-in-person / copy-share-link / unarchive controls hidden when voided - Submission list partial: red VOIDED badge takes priority over expired/completed; per-submitter actions hidden when voided - New submit_form/voided.html.erb — branded landing page rendered to anyone clicking a stale signing link - New submitter_mailer#voided_email and template — surfaces reason ## API Submissions::SerializeForApi reports status: 'voided' and includes void_reason plus voided document URLs. voided_at is serialized alongside archived_at. Submissions::Filter accepts ?status=voided. ## Not included - RSpec specs - Locale parity beyond English - Account-level toggle for the watermark - Distinct :void CanCanCan ability (uses :destroy)pull/669/head
parent
be27ce4161
commit
14c844813d
@ -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