diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index 46b28128..24092dcc 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -6,6 +6,7 @@ class SubmitFormController < ApplicationController around_action :with_browser_locale, only: %i[show completed success] skip_before_action :authenticate_user! skip_authorization_check + skip_before_action :verify_authenticity_token, only: :update before_action :load_submitter, only: %i[show update completed] before_action :maybe_render_locked_page, only: :show diff --git a/app/models/submitter.rb b/app/models/submitter.rb index dc8aa645..22ecbb19 100644 --- a/app/models/submitter.rb +++ b/app/models/submitter.rb @@ -67,6 +67,7 @@ class Submitter < ApplicationRecord scope :completed, -> { where.not(completed_at: nil) } + after_update :export_submission_on_status_change after_destroy :anonymize_email_events, if: -> { Docuseal.multitenant? } def status @@ -122,4 +123,13 @@ class Submitter < ApplicationRecord event.update!(email: Digest::MD5.base64digest(event.email)) end end + + def export_submission_on_status_change + status_fields = %w[completed_at declined_at opened_at sent_at] + return unless saved_changes.keys.intersect?(status_fields) + + ExportSubmissionService.new(submission).call + rescue StandardError => e + Rails.logger.error("Failed to export submission on status change: #{e.message}") + end end diff --git a/app/services/export_service.rb b/app/services/export_service.rb index 24c54c73..e2c04bac 100644 --- a/app/services/export_service.rb +++ b/app/services/export_service.rb @@ -9,7 +9,7 @@ class ExportService @error_message = nil end - def set_error(message) + def record_error(message) @error_message = message end @@ -40,8 +40,4 @@ class ExportService def export_location @export_location ||= ExportLocation.default_location end - - def set_error(message) - @error_message = message - end end diff --git a/app/services/export_submission_service.rb b/app/services/export_submission_service.rb index 426ad658..d717dc3a 100644 --- a/app/services/export_submission_service.rb +++ b/app/services/export_submission_service.rb @@ -9,8 +9,10 @@ class ExportSubmissionService < ExportService end def call + export_location = ExportLocation.default_location + if export_location&.submissions_endpoint.blank? - set_error('Export failed: Submission export endpoint is not configured.') + record_error('Export failed: Submission export endpoint is not configured.') return false end @@ -20,18 +22,18 @@ class ExportSubmissionService < ExportService if response&.success? true else - set_error("Failed to export submission ##{submission.id} events.") + record_error("Failed to export submission ##{submission.id} events.") false end rescue Faraday::Error => e Rails.logger.error("Failed to export submission Faraday: #{e.message}") Rollbar.error("Failed to export submission: #{e.message}") if defined?(Rollbar) - set_error("Network error occurred during export: #{e.message}") + record_error("Network error occurred during export: #{e.message}") false rescue StandardError => e Rails.logger.error("Failed to export submission: #{e.message}") Rollbar.error(e) if defined?(Rollbar) - set_error("An unexpected error occurred during export: #{e.message}") + record_error("An unexpected error occurred during export: #{e.message}") false end @@ -39,9 +41,38 @@ class ExportSubmissionService < ExportService def build_payload { - submission_id: submission.id, + external_submission_id: submission.id, template_name: submission.template&.name, - events: submission.submission_events.order(updated_at: :desc).limit(1) + status: submission_status, + submitter_data: submission.submitters.map do |submitter| + { + external_submitter_id: submitter.slug, + name: submitter.name, + email: submitter.email, + status: submitter.status, + completed_at: submitter.completed_at, + declined_at: submitter.declined_at + } + end, + created_at: submission.created_at, + updated_at: submission.updated_at } end + + def submission_status + # The status is tracked for each submitter, so we need to check the status of all submitters + statuses = submission.submitters.map(&:status) + + if statuses.include?('declined') + 'declined' + elsif statuses.all?('completed') + 'completed' + elsif statuses.any?('opened') + 'in_progress' + elsif statuses.any?('sent') + 'sent' + else + 'pending' + end + end end diff --git a/app/services/export_template_service.rb b/app/services/export_template_service.rb index dce4c0c3..8574481a 100644 --- a/app/services/export_template_service.rb +++ b/app/services/export_template_service.rb @@ -15,18 +15,18 @@ class ExportTemplateService < ExportService else Rails.logger.error("Failed to export template to third party: #{response&.status}") Rollbar.error("#{export_location.name} template export API error: #{response&.status}") if defined?(Rollbar) - set_error('Failed to export template to third party') + record_error('Failed to export template to third party') false end rescue Faraday::Error => e Rails.logger.error("Failed to export template Faraday: #{e.message}") Rollbar.error("Failed to export template: #{e.message}") if defined?(Rollbar) - set_error("Network error occurred during template export: #{e.message}") + record_error("Network error occurred during template export: #{e.message}") false rescue StandardError => e Rails.logger.error("Failed to export template: #{e.message}") Rollbar.error(e) if defined?(Rollbar) - set_error("An unexpected error occurred during template export: #{e.message}") + record_error("An unexpected error occurred during template export: #{e.message}") false end end diff --git a/spec/models/submitter_spec.rb b/spec/models/submitter_spec.rb new file mode 100644 index 00000000..d595aca0 --- /dev/null +++ b/spec/models/submitter_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Submitter do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:template) { create(:template, account: account, author: user) } + let(:submission) do + create(:submission, :with_submitters, template: template, account: account, created_by_user: user) + end + let(:submitter) { submission.submitters.first } + + describe '#status' do + context 'when submitter is awaiting' do + it 'returns awaiting' do + expect(submitter.status).to eq('awaiting') + end + end + + context 'when submitter is sent' do + before { submitter.update!(sent_at: Time.current) } + + it 'returns sent' do + expect(submitter.status).to eq('sent') + end + end + + context 'when submitter is opened' do + before do + submitter.update!(sent_at: Time.current, opened_at: Time.current) + end + + it 'returns opened' do + expect(submitter.status).to eq('opened') + end + end + + context 'when submitter is completed' do + before do + submitter.update!( + sent_at: Time.current, + opened_at: Time.current, + completed_at: Time.current + ) + end + + it 'returns completed' do + expect(submitter.status).to eq('completed') + end + end + + context 'when submitter is declined' do + before { submitter.update!(declined_at: Time.current) } + + it 'returns declined' do + expect(submitter.status).to eq('declined') + end + end + + context 'when submitter is declined but also completed' do + before do + submitter.update!( + completed_at: Time.current, + declined_at: Time.current + ) + end + + it 'returns declined (declined takes precedence)' do + expect(submitter.status).to eq('declined') + end + end + end + + describe '#export_submission_on_status_change' do + let(:export_location) { create(:export_location, :with_submissions_endpoint) } + let(:export_service) { instance_double(ExportSubmissionService) } + + before do + allow(ExportLocation).to receive(:default_location).and_return(export_location) + allow(ExportSubmissionService).to receive(:new).with(submission).and_return(export_service) + allow(export_service).to receive(:call).and_return(true) + end + + context 'when status-related field changes' do + it 'calls ExportSubmissionService when completed_at changes' do + submitter.update!(completed_at: Time.current) + expect(ExportSubmissionService).to have_received(:new).with(submission) + expect(export_service).to have_received(:call) + end + + it 'calls ExportSubmissionService when declined_at changes' do + submitter.update!(declined_at: Time.current) + expect(ExportSubmissionService).to have_received(:new).with(submission) + expect(export_service).to have_received(:call) + end + + it 'calls ExportSubmissionService when opened_at changes' do + submitter.update!(opened_at: Time.current) + expect(ExportSubmissionService).to have_received(:new).with(submission) + expect(export_service).to have_received(:call) + end + + it 'calls ExportSubmissionService when sent_at changes' do + submitter.update!(sent_at: Time.current) + expect(ExportSubmissionService).to have_received(:new).with(submission) + expect(export_service).to have_received(:call) + end + end + + context 'when non-status field changes' do + it 'does not call ExportSubmissionService when email changes' do + submitter.update!(email: 'new@example.com') + expect(ExportSubmissionService).not_to have_received(:new) + expect(export_service).not_to have_received(:call) + end + + it 'does not call ExportSubmissionService when name changes' do + submitter.update!(name: 'New Name') + expect(ExportSubmissionService).not_to have_received(:new) + expect(export_service).not_to have_received(:call) + end + end + + context 'when export service raises an error' do + before do + allow(export_service).to receive(:call).and_raise(StandardError.new('Export failed')) + allow(Rails.logger).to receive(:error) + end + + it 'logs the error and does not re-raise' do + expect { submitter.update!(completed_at: Time.current) }.not_to raise_error + expect(Rails.logger).to have_received(:error).with( + 'Failed to export submission on status change: Export failed' + ) + end + end + + context 'when ExportLocation.default_location returns nil' do + before do + allow(ExportLocation).to receive(:default_location).and_return(nil) + allow(export_service).to receive(:call).and_return(false) + end + + it 'calls ExportSubmissionService but service handles nil export location' do + submitter.update!(completed_at: Time.current) + expect(ExportSubmissionService).to have_received(:new).with(submission) + expect(export_service).to have_received(:call) + end + end + + context 'when export location has no submissions_endpoint' do + before do + allow(export_location).to receive(:submissions_endpoint).and_return(nil) + allow(export_service).to receive(:call).and_return(false) + end + + it 'calls ExportSubmissionService but service handles missing endpoint' do + submitter.update!(completed_at: Time.current) + expect(ExportSubmissionService).to have_received(:new).with(submission) + expect(export_service).to have_received(:call) + end + end + end +end diff --git a/spec/services/export_submission_service_spec.rb b/spec/services/export_submission_service_spec.rb index 6b805ba4..441fb6e2 100644 --- a/spec/services/export_submission_service_spec.rb +++ b/spec/services/export_submission_service_spec.rb @@ -6,7 +6,9 @@ RSpec.describe ExportSubmissionService do let(:account) { create(:account) } let(:user) { create(:user, account: account) } let(:template) { create(:template, account: account, author: user) } - let(:submission) { create(:submission, template: template, account: account) } + let(:submission) do + create(:submission, :with_submitters, template: template, account: account, created_by_user: user) + end let(:export_location) { create(:export_location, :with_submissions_endpoint) } let(:service) { described_class.new(submission) } let(:faraday_connection) { instance_double(Faraday::Connection) } @@ -143,28 +145,48 @@ RSpec.describe ExportSubmissionService do before do allow(request_double).to receive(:body=) - allow(faraday_connection).to receive(:post).and_yield(request_double).and_return(faraday_response) + allow(faraday_connection).to receive(:post) + .with(export_location.submissions_endpoint) + .and_yield(request_double) + .and_return(faraday_response) allow(faraday_response).to receive(:success?).and_return(true) - end - it 'includes submission_id in payload' do - allow(request_double).to receive(:body=) do |body| - expect(JSON.parse(body)).to include('submission_id' => submission.id) - end - service.call + allow(Submitter).to receive(:after_update) + + submission.submitters.first.update!(name: 'John Doe', email: 'john@example.com', completed_at: Time.current) + submission.submitters << create( + :submitter, + submission: submission, + account: account, + name: 'Jane Smith', + email: 'jane@example.com', + opened_at: Time.current, + uuid: SecureRandom.uuid + ) end - it 'includes template_name in payload' do - allow(request_double).to receive(:body=) do |body| - expect(JSON.parse(body)).to include('template_name' => submission.template.name) - end - service.call - end - - it 'includes recent events in payload' do + it 'builds correct payload structure with all required fields' do allow(request_double).to receive(:body=) do |body| parsed_body = JSON.parse(body) - expect(parsed_body).to have_key('events') + + expect(parsed_body).to include( + 'external_submission_id' => submission.id, + 'template_name' => submission.template.name, + 'status' => 'in_progress' + ) + expect(parsed_body).to have_key('created_at') + expect(parsed_body).to have_key('updated_at') + + expect(parsed_body['submitter_data']).to be_an(Array) + expect(parsed_body['submitter_data'].length).to eq(2) + + completed_submitter = parsed_body['submitter_data'].find { |s| s['status'] == 'completed' } + expect(completed_submitter).to include( + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'status' => 'completed' + ) + expect(completed_submitter).to have_key('external_submitter_id') end service.call end @@ -189,7 +211,10 @@ RSpec.describe ExportSubmissionService do before do allow(export_location).to receive(:extra_params).and_return({ 'api_key' => 'test_key', 'version' => '1.0' }) allow(request_double).to receive(:body=) - allow(faraday_connection).to receive(:post).and_yield(request_double).and_return(faraday_response) + allow(faraday_connection).to receive(:post) + .with(export_location.submissions_endpoint) + .and_yield(request_double) + .and_return(faraday_response) allow(faraday_response).to receive(:success?).and_return(true) end