# frozen_string_literal: true module Submissions module GenerateAuditTrail FONT_SIZE = 9 TEXT_COLOR = '525252' FONT_PATH = '/fonts/GoNotoKurrent-Regular.ttf' FONT_BOLD_PATH = '/fonts/GoNotoKurrent-Bold.ttf' FONT_NAME = if File.exist?(FONT_PATH) 'GoNotoKurrent' else 'Helvetica' end CURRENCY_SYMBOLS = { 'USD' => '$', 'EUR' => '€', 'GBP' => '£' }.freeze TESTING_FOOTER = GenerateResultAttachments::TESTING_FOOTER RTL_REGEXP = TextUtils::RTL_REGEXP MAX_IMAGE_HEIGHT = 100 US_TIMEZONES = TimeUtils::US_TIMEZONES module_function # rubocop:disable Metrics def call(submission) account = submission.account last_submitter = submission.submitters.select(&:completed_at).max_by(&:completed_at) I18n.with_locale(last_submitter.metadata.fetch('lang', account.locale)) do document = build_audit_trail(submission) pkcs = Accounts.load_signing_pkcs(account) tsa_url = Accounts.load_timeserver_url(account) io = StringIO.new document.trailer.info[:Creator] = "#{Docuseal.product_name} (#{Docuseal::PRODUCT_URL})" if pkcs sign_params = { reason: sign_reason, **Submissions::GenerateResultAttachments.build_signing_params(last_submitter, pkcs, tsa_url) } document.sign(io, **sign_params) Submissions::GenerateResultAttachments.maybe_enable_ltv(io, sign_params) else document.write(io) end ActiveStorage::Attachment.create!( blob: ActiveStorage::Blob.create_and_upload!( io: io.tap(&:rewind), filename: "#{I18n.t('audit_log')} - " \ "#{submission.name || submission.template.name}.pdf" ), name: 'audit_trail', record: submission ) end end def build_audit_trail(submission) account = submission.account verify_url = Rails.application.routes.url_helpers.settings_esign_url( **Docuseal.default_url_options, host: ENV.fetch('EMAIL_HOST', Docuseal.default_url_options[:host]) ) page_size = if TimeUtils.timezone_abbr(account.timezone, Time.current.beginning_of_year).in?(US_TIMEZONES) :Letter else :A4 end composer = HexaPDF::Composer.new(skip_page_creation: true) if Docuseal.pdf_format == 'pdf/a-3b' composer.document.task(:pdfa, level: '3b') elsif FONT_NAME == 'GoNotoKurrent' composer.document.task(:pdfa) end composer.document.config['font.map'] = { 'Helvetica' => { none: FONT_PATH, bold: FONT_BOLD_PATH }, FONT_NAME => { none: FONT_PATH, bold: FONT_BOLD_PATH } } composer.document.config['font.on_missing_glyph'] = Submissions::GenerateResultAttachments.method(:on_missing_glyph).to_proc divider = HexaPDF::Layout::Box.create( margin: [0, 0, 15, 0], border: { width: [1, 0, 0, 0], color: %w[hp-gray-light] }, height: 1 ) configs = submission.account.account_configs.where(key: [AccountConfig::WITH_AUDIT_VALUES_KEY, AccountConfig::WITH_SIGNATURE_ID, AccountConfig::WITH_FILE_LINKS_KEY, AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY]) last_submitter = submission.submitters.select(&:completed_at).max_by(&:completed_at) with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true with_file_links = configs.find { |c| c.key == AccountConfig::WITH_FILE_LINKS_KEY }&.value == true with_audit_values = configs.find { |c| c.key == AccountConfig::WITH_AUDIT_VALUES_KEY }&.value != false with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true timezone = account.timezone timezone = last_submitter.timezone || account.timezone if with_submitter_timezone composer.page_style(:default, page_size:) 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 maybe_add_background(canvas, submission, page_size) end if with_signature_id || submission.account.testing? canvas.save_graphics_state do document_id = Digest::MD5.hexdigest(submission.slug).upcase font = composer.document.fonts.add(FONT_NAME) text = if submission.account.testing? if with_signature_id "#{TESTING_FOOTER} | ID: #{document_id}" else TESTING_FOOTER end else "#{I18n.t('document_id')}: #{document_id}" end text = HexaPDF::Layout::TextFragment.create( text, font:, font_size: FONT_SIZE, underlays: [ lambda do |canv, box| canv.fill_color('white').rectangle(-1, 0, box.width + 2, box.height).fill end ] ) HexaPDF::Layout::TextLayouter.new(font:, font_size: FONT_SIZE) .fit([text], 1000, 1000) .draw(canvas, 1, FONT_SIZE * 1.37) end 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) composer.style(:link, fill_color: 'hp-blue-light', underline: true) composer.new_page composer.column(columns: 1) do |column| add_logo(column, submission) column.text(account.testing? ? I18n.t('testing_log_not_for_production_use') : I18n.t('audit_log'), font_size: 16, padding: [10, 0, 0, 0], position: :float, text_align: :right) end composer.column(columns: 1) do |column| column.text("#{I18n.t('envelope_id')}: #{submission.id}", font_size: 12, padding: [15, 0, 8, 0], position: :float) if show_verify?(submission) column.formatted_text([{ link: verify_url, text: I18n.t('verify'), style: :link }], font_size: 9, padding: [15, 0, 10, 0], position: :float, text_align: :right) end end composer.draw_box(divider) documents_data = select_attachments(last_submitter).map do |document| original_documents = submission.schema_documents.select do |e| e.uuid == (document.metadata['original_uuid'] || document.uuid) end.presence original_documents ||= submission.template.documents.select do |e| e.image? && submission.template_schema.any? do |item| item['attachment_uuid'] == e.uuid end end [ composer.document.layout.formatted_text_box( [{ text: document.filename.to_s }] ), composer.document.layout.formatted_text_box( [ { text: "#{I18n.t('original_sha256')}:\n", font: [FONT_NAME, { variant: :bold }] }, original_documents.map { |d| d.metadata['sha256'] || d.checksum }.join("\n"), "\n", { text: "#{I18n.t('result_sha256')}:\n", font: [FONT_NAME, { variant: :bold }] }, document.metadata['sha256'] || document.checksum, "\n", { text: "#{I18n.t('generated_at')}: ", font: [FONT_NAME, { variant: :bold }] }, "#{I18n.l(document.created_at.in_time_zone(timezone), format: :long, locale: account.locale)} " \ "#{TimeUtils.timezone_abbr(timezone, document.created_at)}" ], line_spacing: 1.3 ) ] end if documents_data.present? composer.table(documents_data, cell_style: { padding: [0, 0, 20, 0], border: { width: 0 } }) composer.draw_box(divider) end submission.template_submitters.filter_map do |item| submitter = submission.submitters.find { |e| e.uuid == item['uuid'] } next if submitter.blank? 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? } verify_email_event = submission.submission_events.find { |e| e.submitter_id == submitter.id && e.phone_verified? } is_phone_verified = submission.template_fields.any? do |e| e['type'] == 'phone' && e['submitter_uuid'] == submitter.uuid && submitter.values[e['uuid']].present? end verify_phone_event = submission.submission_events.find { |e| e.submitter_id == submitter.id && e.phone_verified? } is_id_verified = submission.template_fields.any? do |e| e['type'] == 'verification' && e['submitter_uuid'] == submitter.uuid && submitter.values[e['uuid']].present? end info_rows = [ [ composer.document.layout.formatted_text_box( [ submission.template_submitters.size > 1 && { text: "#{item['name']}\n" }, submitter.email && { text: "#{submitter.email}\n", font: [FONT_NAME, { variant: :bold }] }, submitter.name && { text: "#{TextUtils.maybe_rtl_reverse(submitter.name)}\n" }, submitter.phone && { text: "#{submitter.phone}\n" } ].compact_blank, line_spacing: 1.3, padding: [0, 20, 0, 0] ) ], [ composer.document.layout.formatted_text_box( [ submitter.email && (click_email_event || verify_email_event) && { text: "#{I18n.t('email_verification')}: #{I18n.t('verified')}\n" }, submitter.phone && (is_phone_verified || verify_phone_event) && { text: "#{I18n.t('phone_verification')}: #{I18n.t('verified')}\n" }, is_id_verified && { text: "#{I18n.t('identity_verification')}: #{I18n.t('verified')}\n" }, completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" }, completed_event.data['sid'] && { text: "#{I18n.t('session_id')}: #{completed_event.data['sid']}\n" }, completed_event.data['ua'] && { text: "User agent: #{completed_event.data['ua']}\n" }, submitter.timezone && { text: "Time zone: #{submitter.timezone.to_s.sub('Kiev', 'Kyiv')}\n" }, "\n" ].compact_blank, line_spacing: 1.3, padding: [10, 20, 20, 0] ) ] ] composer.table(info_rows, cell_style: { padding: [0, 0, 0, 0], border: { width: 0 } }) submitter_field_counters = Hash.new { 0 } grouped_value_field_names = {} skip_grouped_field_uuids = {} submission.template_fields.each do |field| next unless field['type'].in?(%w[signature initials]) submitter_field_counters[field['type']] += 1 next if field['submitter_uuid'] != submitter.uuid value = submitter.values[field['uuid']] field_name = field['title'].presence || field['name'].presence || "#{I18n.t("#{field['type']}_field")} #{submitter_field_counters[field['type']]}" if grouped_value_field_names[value] skip_grouped_field_uuids[field['uuid']] = true grouped_value_field_names[value] += ", #{field_name}" else grouped_value_field_names[value] = field_name end end submitter_field_counters = Hash.new { 0 } submission.template_fields.filter_map do |field| submitter_field_counters[field['type']] += 1 next if field['submitter_uuid'] != submitter.uuid next if field['type'] == 'heading' || field['type'] == 'strikethrough' next if !with_audit_values && !field['type'].in?(%w[signature initials]) next if skip_grouped_field_uuids[field['uuid']] value = submitter.values[field['uuid']] next if Array.wrap(value).compact_blank.blank? field_name = grouped_value_field_names[value].presence || field['title'].presence || field['name'].to_s [ composer.formatted_text_box( [ { text: TextUtils.maybe_rtl_reverse(field_name).upcase.presence || "#{I18n.t("#{field['type']}_field")} #{submitter_field_counters[field['type']]}".upcase, font_size: 6 } ].compact_blank, text_align: field_name.to_s.match?(RTL_REGEXP) ? :right : :left, line_spacing: 1.3, padding: [0, 0, 2, 0] ), if field['type'].in?(%w[image signature initials stamp]) && (attachment = submitter.attachments.find { |a| a.uuid == value }) && attachment.image? image = begin Submissions::GenerateResultAttachments.load_vips_image(attachment).autorot rescue Vips::Error next unless attachment.content_type.starts_with?('image/') next if attachment.byte_size.zero? raise end scale = [600.0 / image.width, 600.0 / image.height].min resized_image = image.resize([scale, 1].min) io = StringIO.new(resized_image.write_to_buffer('.png')) width = field['type'] == 'initials' ? 50 : 200 height = resized_image.height * (width.to_f / resized_image.width) if height > MAX_IMAGE_HEIGHT width = (MAX_IMAGE_HEIGHT / height) * width height = MAX_IMAGE_HEIGHT end composer.image(io, width:, height:, margin: [5, 0, 10, 0]) composer.formatted_text_box([{ text: '' }]) elsif field['type'].in?(%w[file payment image]) if field['type'] == 'payment' unit = CURRENCY_SYMBOLS[field['preferences']['currency']] || field['preferences']['currency'] price = ApplicationController.helpers.number_to_currency(field['preferences']['price'], unit:) composer.formatted_text_box([{ text: "#{I18n.t('paid_price', price:)}\n" }], padding: [0, 0, 10, 0]) end composer.formatted_text_box( Array.wrap(value).map do |uuid| attachment = submitter.attachments.find { |a| a.uuid == uuid } link = if with_file_links ActiveStorage::Blob.proxy_url(attachment.blob) else r.submissions_preview_url(submission.slug, **Docuseal.default_url_options) end { link:, text: "#{attachment.filename}\n", style: :link } end, padding: [0, 0, 10, 0] ) elsif field['type'] == 'checkbox' composer.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0]) else if field['type'] == 'date' value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), account.locale) end value = NumberUtils.format_number(value, field.dig('preferences', 'format')) if field['type'] == 'number' value = value.join(', ') if value.is_a?(Array) if (mask = field.dig('preferences', 'mask').presence) value = TextUtils.mask_value(value, mask) end composer.formatted_text_box([{ text: TextUtils.maybe_rtl_reverse(value.to_s.presence || 'n/a') }], text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left, padding: [0, 0, 10, 0]) end ] end end composer.draw_box(divider) composer.text(I18n.t('event_log'), font_size: 12, padding: [10, 0, 20, 0]) events_data = submission.submission_events.sort_by(&:event_timestamp).filter_map do |event| next if event.event_type.in?(%w[bounce_email complaint_email]) submitter = submission.submitters.find { |e| e.id == event.submitter_id } submitter_name = if event.event_type.include?('sms') || event.event_type.include?('phone') event.data['phone'] || submitter.phone else submitter.name || submitter.email || submitter.phone end text = if event.event_type == 'complete_verification' I18n.t('submission_event_names.complete_verification_by_html', provider: event.data['method'], submitter_name:) elsif event.event_type == 'invite_party' && (invited_submitter = submission.submitters.find { |e| e.uuid == event.data['uuid'] }) && (name = submission.template_submitters.find { |e| e['uuid'] == event.data['uuid'] }&.dig('name')) invited_submitter_name = [invited_submitter.name || invited_submitter.email || invited_submitter.phone, name].join(' ') I18n.t('submission_event_names.invite_party_by_html', invited_submitter_name:, submitter_name:) elsif event.event_type.include?('send_') I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:) else I18n.t("submission_event_names.#{event.event_type}_by_html", submitter_name:) end bold_text, normal_text = text.match(%r{(.*?)(.*)}).captures [ "#{I18n.l(event.event_timestamp.in_time_zone(timezone), format: :long, locale: account.locale)} " \ "#{TimeUtils.timezone_abbr(timezone, event.event_timestamp)}", composer.document.layout.formatted_text_box([{ text: bold_text, font: [FONT_NAME, { variant: :bold }] }, normal_text]) ] end composer.table(events_data, cell_style: { padding: [0, 0, 12, 0], border: { width: 0 } }) if events_data.present? composer.document end def sign_reason 'Signed with DocuSeal.com' end def select_attachments(submitter) original_documents = submitter.submission.schema_documents.preload(:blob) is_more_than_two_images = original_documents.many?(&:image?) submitter.documents.preload(:blob).reject do |attachment| is_more_than_two_images && original_documents.find { |a| a.uuid == (attachment.metadata['original_uuid'] || attachment.uuid) }&.image? end end def maybe_add_background(_canvas, _submission, _page_size); end def show_verify?(submission) !submission.source.in?(%w[embed api]) end def add_logo(column, _submission = nil) column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float) column.formatted_text([{ text: 'DocuSeal', link: Docuseal::PRODUCT_EMAIL_URL }], font_size: 20, font: [FONT_NAME, { variant: :bold }], width: 100, padding: [5, 0, 0, 8], position: :float, text_align: :left) end def r Rails.application.routes.url_helpers end # rubocop:enable Metrics end end