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..f6121788 100644 --- a/app/models/submitter.rb +++ b/app/models/submitter.rb @@ -68,6 +68,7 @@ class Submitter < ApplicationRecord scope :completed, -> { where.not(completed_at: nil) } after_destroy :anonymize_email_events, if: -> { Docuseal.multitenant? } + after_update :export_submission_on_status_change def status if declined_at? @@ -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 & status_fields).any? + + ExportSubmissionService.new(submission).call + rescue => e + Rails.logger.error("Failed to export submission on status change: #{e.message}") + end end diff --git a/app/services/export_submission_service.rb b/app/services/export_submission_service.rb index 426ad658..02ff57b8 100644 --- a/app/services/export_submission_service.rb +++ b/app/services/export_submission_service.rb @@ -9,6 +9,8 @@ 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.') return false @@ -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? { |s| s == 'completed' } + 'completed' + elsif statuses.any? { |s| s == 'opened' } + 'in_progress' + elsif statuses.any? { |s| s == 'sent' } + 'sent' + else + 'pending' + end + end end diff --git a/spec/models/submitter_spec.rb b/spec/models/submitter_spec.rb new file mode 100644 index 00000000..8fc19197 --- /dev/null +++ b/spec/models/submitter_spec.rb @@ -0,0 +1,161 @@ +# 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) { create(:submission, :with_submitters, template: template, account: account, created_by_user: user) } + 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..2b019dc2 100644 --- a/spec/services/export_submission_service_spec.rb +++ b/spec/services/export_submission_service_spec.rb @@ -140,16 +140,19 @@ RSpec.describe ExportSubmissionService do describe 'payload building' do let(:request_double) { instance_double(Faraday::Request, body: nil) } + let(:submitter1) { create(:submitter, submission: submission, account: account, name: 'John Doe', email: 'john@example.com', completed_at: Time.current, uuid: SecureRandom.uuid) } + let(:submitter2) { create(:submitter, submission: submission, account: account, name: 'Jane Smith', email: 'jane@example.com', opened_at: Time.current, uuid: SecureRandom.uuid) } before do + submission.submitters << [submitter1, submitter2] allow(request_double).to receive(:body=) allow(faraday_connection).to receive(:post).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 + it 'includes external_submission_id in payload' do allow(request_double).to receive(:body=) do |body| - expect(JSON.parse(body)).to include('submission_id' => submission.id) + expect(JSON.parse(body)).to include('external_submission_id' => submission.id) end service.call end @@ -161,10 +164,52 @@ RSpec.describe ExportSubmissionService do service.call end - it 'includes recent events in payload' do + it 'includes submission status in payload' do + allow(request_double).to receive(:body=) do |body| + expect(JSON.parse(body)).to include('status' => 'in_progress') + end + service.call + end + + it 'includes submitter_data array in payload' do + allow(request_double).to receive(:body=) do |body| + parsed_body = JSON.parse(body) + expect(parsed_body).to have_key('submitter_data') + expect(parsed_body['submitter_data']).to be_an(Array) + expect(parsed_body['submitter_data'].length).to eq(2) + end + service.call + end + + it 'includes correct submitter data in payload' do allow(request_double).to receive(:body=) do |body| parsed_body = JSON.parse(body) - expect(parsed_body).to have_key('events') + submitter_data = parsed_body['submitter_data'] + + first_submitter = submitter_data.find { |s| s['email'] == 'john@example.com' } + expect(first_submitter).to include( + 'external_submitter_id' => submitter1.slug, + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'status' => 'completed' + ) + + second_submitter = submitter_data.find { |s| s['email'] == 'jane@example.com' } + expect(second_submitter).to include( + 'external_submitter_id' => submitter2.slug, + 'name' => 'Jane Smith', + 'email' => 'jane@example.com', + 'status' => 'opened' + ) + end + service.call + end + + it 'includes created_at and updated_at in payload' do + allow(request_double).to receive(:body=) do |body| + parsed_body = JSON.parse(body) + expect(parsed_body).to have_key('created_at') + expect(parsed_body).to have_key('updated_at') end service.call end @@ -183,6 +228,59 @@ RSpec.describe ExportSubmissionService do end end + describe '#submission_status' do + let(:service) { described_class.new(submission) } + + context 'with multiple submitters' do + let(:submitter1) { create(:submitter, submission: submission, account: account, uuid: SecureRandom.uuid) } + let(:submitter2) { create(:submitter, submission: submission, account: account, uuid: SecureRandom.uuid) } + + before do + submission.submitters << [submitter1, submitter2] + end + + it 'returns declined when any submitter is declined' do + submitter1.declined_at = Time.current + submitter2.completed_at = Time.current + expect(service.send(:submission_status)).to eq('declined') + end + + it 'returns completed when all submitters are completed' do + submitter1.completed_at = Time.current + submitter2.completed_at = Time.current + expect(service.send(:submission_status)).to eq('completed') + end + + it 'returns in_progress when any submitter is opened but not all completed' do + submitter1.opened_at = Time.current + submitter2.sent_at = Time.current + expect(service.send(:submission_status)).to eq('in_progress') + end + + it 'returns sent when any submitter is sent but none opened' do + submitter1.sent_at = Time.current + expect(service.send(:submission_status)).to eq('sent') + end + + it 'returns pending when no submitters have been sent' do + expect(service.send(:submission_status)).to eq('pending') + end + end + + context 'with single submitter' do + let(:submitter) { create(:submitter, submission: submission, account: account, uuid: SecureRandom.uuid) } + + before do + submission.submitters << submitter + end + + it 'returns the submitter status when single submitter' do + submitter.opened_at = Time.current + expect(service.send(:submission_status)).to eq('in_progress') + end + end + end + describe 'extra_params handling' do let(:request_double) { instance_double(Faraday::Request, body: nil) }