diff --git a/Dockerfile b/Dockerfile index 14e596bb..acf9dfbf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ ENV BUNDLE_WITHOUT="development:test" WORKDIR /app -RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /LiberationSans-Regular.ttf && apk del ttf-liberation +RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf / && apk del ttf-liberation COPY ./Gemfile ./Gemfile.lock ./ diff --git a/app/controllers/submissions_debug_controller.rb b/app/controllers/submissions_debug_controller.rb index 952c651f..2b3bab97 100644 --- a/app/controllers/submissions_debug_controller.rb +++ b/app/controllers/submissions_debug_controller.rb @@ -15,9 +15,13 @@ class SubmissionsDebugController < ApplicationController render 'submit_form/show' end f.pdf do - Submissions::GenerateResultAttachments.call(@submitter) + if params[:audit] + Submissions::GenerateAuditTrail.call(@submitter.submission) + else + Submissions::GenerateResultAttachments.call(@submitter) + end - send_data ActiveStorage::Attachment.where(name: :documents).last.download, + send_data ActiveStorage::Attachment.where(name: params[:audit] ? :audit_trail : :documents).last.download, filename: 'debug.pdf', disposition: 'inline', type: 'application/pdf' diff --git a/app/jobs/process_submitter_completion_job.rb b/app/jobs/process_submitter_completion_job.rb index 89178615..513f2a6a 100644 --- a/app/jobs/process_submitter_completion_job.rb +++ b/app/jobs/process_submitter_completion_job.rb @@ -17,6 +17,8 @@ class ProcessSubmitterCompletionJob < ApplicationJob return unless is_all_completed return if submitter.completed_at != submitter.submission.submitters.maximum(:completed_at) + Submissions::GenerateAuditTrail.call(submitter.submission) + enqueue_emails(submitter) end diff --git a/app/mailers/submitter_mailer.rb b/app/mailers/submitter_mailer.rb index 490d22f2..7c0eb1e9 100644 --- a/app/mailers/submitter_mailer.rb +++ b/app/mailers/submitter_mailer.rb @@ -33,11 +33,7 @@ class SubmitterMailer < ApplicationMailer @email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY) - documents = Submitters.select_attachments_for_download(submitter) - - documents.each do |attachment| - attachments[attachment.filename.to_s] = attachment.download - end + add_completed_email_attachments!(submitter) subject = if @email_config @@ -60,11 +56,7 @@ class SubmitterMailer < ApplicationMailer Submissions::EnsureResultGenerated.call(@submitter) - @documents = Submitters.select_attachments_for_download(submitter) - - @documents.each do |attachment| - attachments[attachment.filename.to_s] = attachment.download - end + @documents = add_completed_email_attachments!(submitter) @email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY) @@ -82,6 +74,20 @@ class SubmitterMailer < ApplicationMailer private + def add_completed_email_attachments!(submitter) + documents = Submitters.select_attachments_for_download(submitter) + + documents.each do |attachment| + attachments[attachment.filename.to_s] = attachment.download + end + + if submitter.submission.audit_trail.present? + attachments[submitter.submission.audit_trail.filename.to_s] = submitter.submission.audit_trail.download + end + + documents + end + def from_address_for_submitter(submitter) submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name end diff --git a/app/models/submission.rb b/app/models/submission.rb index 830a1343..d27ce2ed 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -41,6 +41,8 @@ class Submission < ApplicationRecord attribute :source, :string, default: 'link' attribute :submitters_order, :string, default: 'random' + has_one_attached :audit_trail + has_many :template_schema_documents, ->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) }, through: :template, source: :documents_attachments diff --git a/lib/pdf_icons.rb b/lib/pdf_icons.rb index 424e2aab..6dac0b1c 100644 --- a/lib/pdf_icons.rb +++ b/lib/pdf_icons.rb @@ -15,4 +15,8 @@ module PdfIcons def paperclip_io @paperclip_io ||= StringIO.new(PATH.join('paperclip.png').read) end + + def logo_io + @logo_io ||= StringIO.new(PATH.join('logo.png').read) + end end diff --git a/lib/pdf_icons/logo.png b/lib/pdf_icons/logo.png new file mode 100644 index 00000000..ee77f2ad Binary files /dev/null and b/lib/pdf_icons/logo.png differ diff --git a/lib/submission_events.rb b/lib/submission_events.rb index a804a5f3..9ec2a6a1 100644 --- a/lib/submission_events.rb +++ b/lib/submission_events.rb @@ -3,6 +3,17 @@ module SubmissionEvents TRACKING_PARAM_LENGTH = 6 + EVENT_NAMES = { + send_email: 'Email sent', + send_sms: 'SMS sent', + open_email: 'Email opened', + click_email: 'Email link clicked', + click_sms: 'SMS link clicked', + start_form: 'Submission started', + view_form: 'Form viewed', + complete_form: 'Submission completed' + }.freeze + module_function def build_tracking_param(submitter, event_type = 'click_email') diff --git a/lib/submissions/generate_audit_trail.rb b/lib/submissions/generate_audit_trail.rb new file mode 100644 index 00000000..23184444 --- /dev/null +++ b/lib/submissions/generate_audit_trail.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +module Submissions + module GenerateAuditTrail + FONT_SIZE = 9 + TEXT_COLOR = '525252' + FONT_PATH = '/LiberationSans-Regular.ttf' + FONT_BOLD_PATH = '/LiberationSans-Bold.ttf' + FONT_NAME = if File.exist?(FONT_PATH) + FONT_PATH + else + 'Helvetica' + end + FONT_BOLD_NAME = if File.exist?(FONT_BOLD_PATH) + FONT_BOLD_PATH + else + 'Helvetica' + end + + INFO_CREATOR = "#{Docuseal::PRODUCT_NAME} (#{Docuseal::PRODUCT_URL})".freeze + SIGN_REASON = 'Signed with DocuSeal.co' + + module_function + + # rubocop:disable Metrics + def call(submission) + account = submission.template.account + pkcs = Accounts.load_signing_pkcs(account) + verify_url = Rails.application.routes.url_helpers.settings_esign_url(**Docuseal.default_url_options) + + composer = HexaPDF::Composer.new(skip_page_creation: true) + composer.document.fonts.add(FONT_BOLD_NAME, variant: :bold) + + divider = HexaPDF::Layout::Box.create( + margin: [0, 0, 15, 0], + border: { + width: [1, 0, 0, 0], + color: %w[hp-gray-light] + }, + height: 1 + ) + + composer.page_style(:default, page_size: :A4) do |canvas, style| + box = canvas.context.box(:media) + canvas.save_graphics_state do + canvas.fill_color('FAF7F5') + .rectangle(0, 0, box.width, 20) + .rectangle(0, box.height - 20, box.width, 20) + .fill + end + style.frame = style.create_frame(canvas.context, 50) + end + + composer.style(:base, font: FONT_NAME, font_size: FONT_SIZE, fill_color: TEXT_COLOR, line_spacing: 1.2) + composer.style(:link, fill_color: 'hp-blue-light', underline: true) + + composer.new_page + + composer.column(columns: 1) do |column| + column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float) + + column.text('DocuSeal', + font_size: 20, + font: [FONT_BOLD_NAME, { variant: :bold }], + width: 100, + padding: [11, 0, 0, 8], + position: :float, position_hint: :left) + + column.text('Audit Log', + font_size: 16, + padding: [15, 0, 0, 0], + position: :float, position_hint: :right) + end + + composer.column(columns: 1) do |column| + column.text("Envelope ID: #{submission.id}", font_size: 12, padding: [20, 0, 10, 0], position: :float) + column.formatted_text([ + { link: verify_url, text: 'Verify', style: :link } + ], font_size: 9, padding: [22, 0, 10, 0], position: :float, align: :right) + end + + composer.draw_box(divider) + + last_submitter = submission.submitters.where.not(completed_at: nil).order(:completed_at).last + + documents_data = Submitters.select_attachments_for_download(last_submitter).map do |document| + original_documents = submission.template.documents.select { |e| e.uuid == document.uuid }.presence + original_documents ||= submission.template.documents.select do |e| + e.image? && submission.schema.any? do |item| + item['attachment_uuid'] == e.uuid + end + end + + [document.filename.to_s, + composer.document.layout.formatted_text_box( + [ + { text: "Original SHA256:\n", font: [FONT_BOLD_NAME, { variant: :bold }] }, + original_documents.map { |d| d.metadata['sha256'] || d.checksum }.join("\n"), + "\n", + { text: "Result SHA256:\n", font: [FONT_BOLD_NAME, { variant: :bold }] }, + document.metadata['sha256'] || document.checksum, + "\n", + { text: 'Generated at: ', font: [FONT_BOLD_NAME, { variant: :bold }] }, + I18n.l(document.created_at, format: :long, locale: account.locale) + ], line_spacing: 1.8 + )] + end + + composer.table(documents_data, cell_style: { padding: [0, 0, 25, 0], border: { width: 0 } }) + + composer.draw_box(divider) + + submitters_data = submission.template_submitters.map do |item| + submitter = submission.submitters.find { |e| e.uuid == item['uuid'] } + completed_event = + submission.submission_events.find { |e| e.submitter_id == submitter.id && e.complete_form? } || + SubmissionEvent.new + + click_email_event = + submission.submission_events.find { |e| e.submitter_id == submitter.id && e.click_email? } + is_phone_verified = + submission.template_fields.any? { |e| e['type'] == 'phone' && e['submitter_uuid'] == submitter.uuid } + + submitter_field_counters = Hash.new { 0 } + + [ + [ + composer.document.layout.formatted_text_box( + [ + submission.template_submitters.size > 1 && { text: "#{item['name']}\n" }, + submitter.email && { text: "#{submitter.email}\n", font: [FONT_BOLD_NAME, { variant: :bold }] }, + submitter.name && { text: "#{submitter.name}\n" }, + submitter.phone && { text: "#{submitter.phone}\n" } + ].compact_blank, line_spacing: 1.8, padding: [0, 20, 0, 0] + ), + composer.document.layout.formatted_text_box( + [ + submitter.email && { + text: "Email verification: #{click_email_event ? 'Verified by DocuSeal' : 'Unverififed'}\n" + }, + submitter.phone && { + text: "Phone verification: #{is_phone_verified ? 'Verified by DocuSeal' : 'Unverififed'}\n" + }, + completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" }, + completed_event.data['sid'] && { text: "Session ID: #{completed_event.data['sid']}\n" }, + completed_event.data['ua'] && { text: "User agent: #{completed_event.data['ua']}\n" }, + "\n" + ].compact_blank, line_spacing: 1.8, padding: [10, 20, 0, 0] + ) + ], + submission.template_fields.filter_map do |field| + next if field['submitter_uuid'] != submitter.uuid + + submitter_field_counters[field['type']] += 1 + + value = submitter.values[field['uuid']] + + [ + composer.document.layout.formatted_text_box( + [ + { + text: field['name'].presence || + "#{field['type'].titleize} Field #{submitter_field_counters[field['type']]}\n".upcase, + font_size: 6 + } + ].compact_blank, line_spacing: 1.8, padding: [0, 0, 5, 0] + ), + if field['type'].in?(%w[image signature]) + attachment = submitter.attachments.find { |a| a.uuid == value } + image = Vips::Image.new_from_buffer(attachment.download, '').autorot + + scale = [300.0 / image.width, 300.0 / image.height].min + + io = StringIO.new(image.resize([scale, 1].min).write_to_buffer('.png')) + + composer.document.layout.image(io, padding: [0, 100, 10, 0]) + elsif field['type'] == 'file' + composer.document.layout.formatted_text_box( + Array.wrap(value).map do |uuid| + attachment = submitter.attachments.find { |a| a.uuid == uuid } + link = + Rails.application.routes.url_helpers.rails_blob_url(attachment, **Docuseal.default_url_options) + + { link:, text: "#{attachment.filename}\n", style: :link } + end, + padding: [0, 0, 10, 0] + ) + elsif field['type'] == 'checkbox' + composer.document.layout.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0]) + else + value = I18n.l(Date.parse(value), format: :long, locale: account.locale) if field['type'] == 'date' + value = value.join(', ') if value.is_a?(Array) + + composer.document.layout.formatted_text_box([{ text: value.to_s.presence || 'n/a' }], + padding: [0, 0, 10, 0]) + end + ] + end.flatten + ] + end + + composer.table(submitters_data, cell_style: { padding: [0, 0, 25, 0], border: { width: 0 } }) + + composer.draw_box(divider) + + composer.text('Event Log', font_size: 12, padding: [20, 0, 20, 0]) + + events_data = submission.submission_events.sort_by(&:event_timestamp).map do |event| + submitter = submission.submitters.find { |e| e.id == event.submitter_id } + [ + I18n.l(event.event_timestamp, format: :long, locale: account.locale), + composer.document.layout.formatted_text_box( + [ + { text: SubmissionEvents::EVENT_NAMES[event.event_type.to_sym], + font: [FONT_BOLD_NAME, { variant: :bold }] }, + event.event_type.include?('send_') ? ' to ' : ' by ', + if event.event_type.include?('sms') + submitter.phone + else + (submitter.email || submitter.name || submitter.phone) + end + ] + ) + ] + end + + composer.table(events_data, cell_style: { padding: [0, 0, 20, 0], border: { width: 0 } }) + + io = StringIO.new + + composer.document.trailer.info[:Creator] = INFO_CREATOR + + composer.document.sign(io, reason: SIGN_REASON, + certificate: pkcs.certificate, + key: pkcs.key, + certificate_chain: pkcs.ca_certs || []) + + ActiveStorage::Attachment.create!( + blob: ActiveStorage::Blob.create_and_upload!( + io: StringIO.new(io.string), filename: "Audit Log - #{submission.template.name}.pdf" + ), + name: 'audit_trail', + record: submission + ) + end + # rubocop:enable Metrics + end +end diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb index 14d73f9e..83fe5831 100644 --- a/lib/submissions/generate_result_attachments.rb +++ b/lib/submissions/generate_result_attachments.rb @@ -28,7 +28,8 @@ module Submissions template = submitter.submission.template - pkcs = Accounts.load_signing_pkcs(submitter.submission.template.account) + account = submitter.submission.template.account + pkcs = Accounts.load_signing_pkcs(account) pdfs_index = build_pdfs_index(submitter) @@ -149,7 +150,7 @@ module Submissions height - (area['y'] * height)) end else - value = I18n.l(Date.parse(value), format: :long) if field['type'] == 'date' + value = I18n.l(Date.parse(value), format: :long, locale: account.locale) if field['type'] == 'date' text = HexaPDF::Layout::TextFragment.create(Array.wrap(value).join(', '), font: pdf.fonts.add(FONT_NAME), font_size:) @@ -214,6 +215,7 @@ module Submissions blob: ActiveStorage::Blob.create_and_upload!( io: StringIO.new(io.string), filename: "#{name}.pdf" ), + metadata: { sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) }, name: 'documents', record: submitter ) diff --git a/lib/templates/create_attachments.rb b/lib/templates/create_attachments.rb index 74e0da2d..5301c7ec 100644 --- a/lib/templates/create_attachments.rb +++ b/lib/templates/create_attachments.rb @@ -19,6 +19,7 @@ module Templates if blob.content_type == PDF_CONTENT_TYPE && blob.metadata['pdf'].nil? blob.metadata['pdf'] = { 'annotations' => Templates::BuildAnnotations.call(document_data) } + blob.metadata['sha256'] = Base64.urlsafe_encode64(Digest::SHA256.digest(document_data)) end blob.save! @@ -37,6 +38,7 @@ module Templates if file.content_type == PDF_CONTENT_TYPE metadata = { 'identified' => true, 'analyzed' => true, + 'sha256' => Base64.urlsafe_encode64(Digest::SHA256.digest(data)), 'pdf' => { 'annotations' => Templates::BuildAnnotations.call(data) } } end