Merge pull request #2 from CareerPlug/CP-9698-export-to-ats

CP-9698 export to ats
pull/501/head
Ryan Arakawa 3 months ago committed by GitHub
commit a317c030a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,6 +1,7 @@
# frozen_string_literal: true
class EmailSmtpSettingsController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, only: :create

@ -7,56 +7,28 @@ class ExportController < ApplicationController
skip_before_action :maybe_redirect_to_setup
skip_before_action :verify_authenticity_token
# Template is sent as JSON already; we're just gonnna send it on to the third party.
# Send template to third party.
def export_template
export_location = ExportLocation.default_location
data = request.raw_post.present? ? JSON.parse(request.raw_post) : params.to_unsafe_h
response = post_to_api(data, export_location.templates_endpoint, export_location.extra_params)
service = ExportTemplateService.new(data)
if response&.success?
Rails.logger.info("Successfully exported template #{data[:template][:name]} to #{export_location.name}")
if service.call
head :ok
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)
head :unprocessable_entity
redirect_to root_path, alert: service.error_message
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)
head :service_unavailable
rescue StandardError => e
Rails.logger.error("Failed to export template: #{e.message}")
Rollbar.error(e) if defined?(Rollbar)
head :internal_server_error
end
private
# Send submission to third party.
def export_submission
submission = Submission.find(params[:id])
service = ExportSubmissionService.new(submission)
def api_connection
@api_connection ||= Faraday.new(url: ExportLocation.default_location.api_base_url) do |faraday|
faraday.request :json
faraday.response :json
faraday.adapter Faraday.default_adapter
if service.call
redirect_to submission, notice: "Submission ##{submission.id} events exported successfully."
else
redirect_to submission, alert: service.error_message
end
rescue StandardError => e
Rails.logger.error("Failed to create API connection: #{e.message}")
Rollbar.error(e) if defined?(Rollbar)
nil
end
def post_to_api(data, endpoint, extra_params = nil)
connection = api_connection
return nil unless connection
connection.post(endpoint) do |req|
# req.headers['Authorization'] = "Bearer #{export_location.authorization_token}" lol
# Merge extra_params into data if provided
data = data.merge(extra_params) if extra_params.present? && data.is_a?(Hash)
req.body = data.is_a?(String) ? data : data.to_json
end
end
end

@ -1,6 +1,7 @@
# frozen_string_literal: true
class SubmissionsController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :load_template, only: %i[new create]
authorize_resource :template, only: %i[new create]

@ -1,6 +1,7 @@
# frozen_string_literal: true
class TemplateDocumentsController < ApplicationController
skip_before_action :verify_authenticity_token
load_and_authorize_resource :template
def create

@ -13,7 +13,7 @@
# Indexes
#
# index_document_generation_events_on_submitter_id (submitter_id)
# index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))
# index_document_generation_events_on_submitter_id_and_event_name (submitter_id,event_name) UNIQUE WHERE ((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[]))
#
# Foreign Keys
#

@ -20,7 +20,7 @@
#
# index_email_events_on_account_id_and_event_datetime (account_id,event_datetime)
# index_email_events_on_email (email)
# index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY (ARRAY[('bounce'::character varying)::text, ('soft_bounce'::character varying)::text, ('complaint'::character varying)::text, ('soft_complaint'::character varying)::text]))
# index_email_events_on_email_event_types (email) WHERE ((event_type)::text = ANY ((ARRAY['bounce'::character varying, 'soft_bounce'::character varying, 'complaint'::character varying, 'soft_complaint'::character varying])::text[]))
# index_email_events_on_emailable (emailable_type,emailable_id)
# index_email_events_on_message_id (message_id)
#

@ -2,15 +2,16 @@
#
# Table name: export_locations
#
# id :bigint not null, primary key
# api_base_url :string not null
# authorization_token :string
# default_location :boolean default(FALSE), not null
# extra_params :jsonb not null
# name :string not null
# templates_endpoint :string
# created_at :datetime not null
# updated_at :datetime not null
# id :bigint not null, primary key
# api_base_url :string not null
# authorization_token :string
# default_location :boolean default(FALSE), not null
# extra_params :jsonb not null
# name :string not null
# submissions_endpoint :string
# templates_endpoint :string
# created_at :datetime not null
# updated_at :datetime not null
#
class ExportLocation < ApplicationRecord
validates :name, presence: true

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'faraday'
class ExportService
attr_reader :error_message
def initialize
@error_message = nil
end
protected
def api_connection
@api_connection ||= Faraday.new(url: ExportLocation.default_location.api_base_url) do |faraday|
faraday.request :json
faraday.response :json
faraday.adapter Faraday.default_adapter
end
rescue StandardError => e
Rails.logger.error("Failed to create API connection: #{e.message}")
Rollbar.error(e) if defined?(Rollbar)
nil
end
def post_to_api(data, endpoint, extra_params = nil)
connection = api_connection
return nil unless connection
connection.post(endpoint) do |req|
data = data.merge(extra_params) if extra_params.present? && data.is_a?(Hash)
req.body = data.is_a?(String) ? data : data.to_json
end
end
def export_location
@export_location ||= ExportLocation.default_location
end
def set_error(message)
@error_message = message
end
end

@ -0,0 +1,47 @@
# frozen_string_literal: true
class ExportSubmissionService < ExportService
attr_reader :submission
def initialize(submission)
super()
@submission = submission
end
def call
unless export_location&.submissions_endpoint.present?
set_error('Export failed: Submission export endpoint is not configured.')
return false
end
payload = build_payload
response = post_to_api(payload, export_location.submissions_endpoint, export_location.extra_params)
if response&.success?
true
else
set_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}")
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}")
false
end
private
def build_payload
{
submission_id: submission.id,
template_name: submission.template&.name,
events: submission.submission_events.order(updated_at: :desc).limit(1)
}
end
end

@ -0,0 +1,32 @@
# frozen_string_literal: true
class ExportTemplateService < ExportService
def initialize(data)
super()
@data = data
end
def call
response = post_to_api(@data, export_location.templates_endpoint, export_location.extra_params)
if response&.success?
Rails.logger.info("Successfully exported template #{@data[:template][:name]} to #{export_location.name}")
true
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")
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}")
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}")
false
end
end

@ -101,6 +101,9 @@
<%= t('view') %>
</a>
</div>
<span data-tip="<%= t('export') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, icon: svg_icon('upload', class: 'w-6 h-6')), export_submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('export'), method: :post %>
</span>
<% if !submission.archived_at? && !template&.archived_at? && can?(:destroy, submission) %>
<span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>

@ -81,6 +81,8 @@ Rails.application.routes.draw do
resources :submissions_archived, only: %i[index], path: 'submissions/archived'
resources :submissions, only: %i[index], controller: 'submissions_dashboard'
resources :submissions, only: %i[show destroy] do
post :export, on: :member, to: 'export#export_submission'
resources :unarchive, only: %i[create], controller: 'submissions_unarchive'
resources :events, only: %i[index], controller: 'submission_events'
end

@ -0,0 +1,5 @@
class AddSubmissionsEndpointToExportLocations < ActiveRecord::Migration[8.0]
def change
add_column :export_locations, :submissions_endpoint, :string
end
end

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_07_03_143236) do
ActiveRecord::Schema[8.0].define(version: 2025_07_08_172115) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@ -160,7 +160,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_03_143236) do
t.string "event_name", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["submitter_id", "event_name"], name: "index_document_generation_events_on_submitter_id_and_event_name", unique: true, where: "((event_name)::text = ANY (ARRAY[('start'::character varying)::text, ('complete'::character varying)::text]))"
t.index ["submitter_id", "event_name"], name: "index_document_generation_events_on_submitter_id_and_event_name", unique: true, where: "((event_name)::text = ANY ((ARRAY['start'::character varying, 'complete'::character varying])::text[]))"
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
end
@ -177,7 +177,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_03_143236) do
t.datetime "created_at", null: false
t.index ["account_id", "event_datetime"], name: "index_email_events_on_account_id_and_event_datetime"
t.index ["email"], name: "index_email_events_on_email"
t.index ["email"], name: "index_email_events_on_email_event_types", where: "((event_type)::text = ANY (ARRAY[('bounce'::character varying)::text, ('soft_bounce'::character varying)::text, ('complaint'::character varying)::text, ('soft_complaint'::character varying)::text]))"
t.index ["email"], name: "index_email_events_on_email_event_types", where: "((event_type)::text = ANY ((ARRAY['bounce'::character varying, 'soft_bounce'::character varying, 'complaint'::character varying, 'soft_complaint'::character varying])::text[]))"
t.index ["emailable_type", "emailable_id"], name: "index_email_events_on_emailable"
t.index ["message_id"], name: "index_email_events_on_message_id"
end
@ -225,6 +225,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_03_143236) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "extra_params", default: {}, null: false
t.string "submissions_endpoint"
end
create_table "oauth_access_grants", force: :cascade do |t|

@ -0,0 +1,29 @@
# frozen_string_literal: true
FactoryBot.define do
factory :export_location do
name { Faker::Company.name }
api_base_url { 'https://api.example.com' }
default_location { false }
extra_params { {} }
templates_endpoint { '/templates' }
submissions_endpoint { nil }
authorization_token { nil }
trait :with_submissions_endpoint do
submissions_endpoint { '/submissions' }
end
trait :with_authorization_token do
authorization_token { SecureRandom.hex(32) }
end
trait :default do
default_location { true }
end
trait :with_extra_params do
extra_params { { 'api_key' => 'test_key', 'version' => '1.0' } }
end
end
end

@ -0,0 +1,200 @@
# frozen_string_literal: true
require 'rails_helper'
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(:export_location) { create(:export_location, :with_submissions_endpoint) }
let(:service) { described_class.new(submission) }
let(:faraday_connection) { instance_double(Faraday::Connection) }
let(:faraday_response) { instance_double(Faraday::Response) }
before do
allow(ExportLocation).to receive(:default_location).and_return(export_location)
allow(Faraday).to receive(:new).and_return(faraday_connection)
end
describe '#call' do
context 'when export location is not configured' do
before do
allow(ExportLocation).to receive(:default_location).and_return(nil)
end
it 'returns false and sets error message' do
expect(service.call).to be false
expect(service.error_message).to eq('Export failed: Submission export endpoint is not configured.')
end
end
context 'when export location has no submissions endpoint' do
before do
allow(export_location).to receive(:submissions_endpoint).and_return(nil)
end
it 'returns false and sets error message' do
expect(service.call).to be false
expect(service.error_message).to eq('Export failed: Submission export endpoint is not configured.')
end
end
context 'when export location is properly configured' do
let(:request_double) { double('request', body: nil) }
before do
allow(request_double).to receive(:body=)
allow(faraday_connection).to receive(:post).and_yield(request_double).and_return(faraday_response)
end
context 'when API request succeeds' do
before do
allow(faraday_response).to receive(:success?).and_return(true)
end
it 'returns true' do
expect(service.call).to be true
end
it 'makes API call with correct endpoint' do
expect(faraday_connection).to receive(:post).with(export_location.submissions_endpoint)
service.call
end
end
context 'when API request fails' do
before do
allow(faraday_response).to receive(:success?).and_return(false)
end
it 'returns false and sets error message' do
expect(service.call).to be false
expect(service.error_message).to eq("Failed to export submission ##{submission.id} events.")
end
end
context 'when API response is nil' do
before do
allow(faraday_connection).to receive(:post).and_return(nil)
end
it 'returns false and sets error message' do
expect(service.call).to be false
expect(service.error_message).to eq("Failed to export submission ##{submission.id} events.")
end
end
end
context 'when Faraday error occurs' do
before do
allow(faraday_connection).to receive(:post).and_raise(Faraday::ConnectionFailed.new('Connection failed'))
end
it 'returns false and sets network error message' do
expect(service.call).to be false
expect(service.error_message).to eq('Network error occurred during export: Connection failed')
end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with('Failed to export submission Faraday: Connection failed')
service.call
end
it 'reports to Rollbar if available' do
stub_const('Rollbar', double)
expect(Rollbar).to receive(:error).with('Failed to export submission: Connection failed')
service.call
end
end
context 'when other standard error occurs' do
before do
allow(ExportLocation).to receive(:default_location).and_raise(StandardError.new('Database error'))
end
it 'returns false and sets generic error message' do
expect(service.call).to be false
expect(service.error_message).to eq('An unexpected error occurred during export: Database error')
end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with('Failed to export submission: Database error')
service.call
end
it 'reports to Rollbar if available' do
stub_const('Rollbar', double)
error = StandardError.new('Database error')
allow(ExportLocation).to receive(:default_location).and_raise(error)
expect(Rollbar).to receive(:error).with(error)
service.call
end
end
end
describe 'payload building' do
let(:request_double) { double('request', body: nil) }
before do
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
expect(request_double).to receive(:body=) do |body|
expect(JSON.parse(body)).to include('submission_id' => submission.id)
end
service.call
end
it 'includes template_name in payload' do
expect(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
expect(request_double).to receive(:body=) do |body|
parsed_body = JSON.parse(body)
expect(parsed_body).to have_key('events')
end
service.call
end
context 'when template is nil' do
before do
allow(submission).to receive(:template).and_return(nil)
end
it 'includes nil template_name in payload' do
expect(request_double).to receive(:body=) do |body|
expect(JSON.parse(body)).to include('template_name' => nil)
end
service.call
end
end
end
describe 'extra_params handling' do
let(:extra_params) { { 'api_key' => 'test_key', 'version' => '1.0' } }
let(:request_double) { double('request', body: nil) }
before do
allow(export_location).to receive(:extra_params).and_return(extra_params)
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 'merges extra_params into the payload' do
expect(request_double).to receive(:body=) do |body|
parsed_body = JSON.parse(body)
expect(parsed_body).to include('api_key' => 'test_key', 'version' => '1.0')
end
service.call
end
end
end

@ -0,0 +1,158 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ExportTemplateService do
let(:export_location) { create(:export_location, :default) }
let(:data) { { template: { name: 'Test Template' } } }
let(:service) { described_class.new(data) }
let(:faraday_connection) { instance_double(Faraday::Connection) }
let(:faraday_response) { instance_double(Faraday::Response) }
before do
allow(ExportLocation).to receive(:default_location).and_return(export_location)
allow(Faraday).to receive(:new).and_return(faraday_connection)
end
describe '#call' do
let(:request_double) { double('request', body: nil) }
before do
allow(request_double).to receive(:body=)
allow(faraday_connection).to receive(:post).and_yield(request_double).and_return(faraday_response)
end
context 'when API request succeeds' do
before do
allow(faraday_response).to receive(:success?).and_return(true)
end
it 'returns true' do
expect(service.call).to be true
end
it 'makes API call with correct endpoint' do
expect(faraday_connection).to receive(:post).with(export_location.templates_endpoint)
service.call
end
it 'logs success message' do
expect(Rails.logger).to receive(:info).with("Successfully exported template Test Template to #{export_location.name}")
service.call
end
end
context 'when API request fails' do
before do
allow(faraday_response).to receive(:success?).and_return(false)
allow(faraday_response).to receive(:status).and_return(422)
end
it 'returns false and sets error message' do
expect(service.call).to be false
expect(service.error_message).to eq('Failed to export template to third party')
end
it 'logs error message' do
expect(Rails.logger).to receive(:error).with('Failed to export template to third party: 422')
service.call
end
it 'reports to Rollbar if available' do
stub_const('Rollbar', double)
expect(Rollbar).to receive(:error).with("#{export_location.name} template export API error: 422")
service.call
end
end
context 'when API response is nil' do
before do
allow(faraday_connection).to receive(:post).and_return(nil)
end
it 'returns false and sets error message' do
expect(service.call).to be false
expect(service.error_message).to eq('Failed to export template to third party')
end
end
context 'when Faraday error occurs' do
before do
allow(faraday_connection).to receive(:post).and_raise(Faraday::ConnectionFailed.new('Connection failed'))
end
it 'returns false and sets network error message' do
expect(service.call).to be false
expect(service.error_message).to eq('Network error occurred during template export: Connection failed')
end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with('Failed to export template Faraday: Connection failed')
service.call
end
it 'reports to Rollbar if available' do
stub_const('Rollbar', double)
expect(Rollbar).to receive(:error).with('Failed to export template: Connection failed')
service.call
end
end
context 'when other standard error occurs' do
before do
allow(ExportLocation).to receive(:default_location).and_raise(StandardError.new('Database error'))
end
it 'returns false and sets generic error message' do
expect(service.call).to be false
expect(service.error_message).to eq('An unexpected error occurred during template export: Database error')
end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with('Failed to export template: Database error')
service.call
end
it 'reports to Rollbar if available' do
stub_const('Rollbar', double)
error = StandardError.new('Database error')
allow(ExportLocation).to receive(:default_location).and_raise(error)
expect(Rollbar).to receive(:error).with(error)
service.call
end
end
end
describe 'data handling' do
let(:request_double) { double('request', body: nil) }
before do
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 'sends the data in the request body' do
expect(request_double).to receive(:body=) do |body|
expect(JSON.parse(body)).to eq(data.deep_stringify_keys)
end
service.call
end
context 'when extra_params are provided' do
let(:extra_params) { { 'api_key' => 'test_key', 'version' => '1.0' } }
before do
allow(export_location).to receive(:extra_params).and_return(extra_params)
end
it 'merges extra_params into the data' do
expect(request_double).to receive(:body=) do |body|
parsed_body = JSON.parse(body)
expect(parsed_body).to include('api_key' => 'test_key', 'version' => '1.0')
end
service.call
end
end
end
end
Loading…
Cancel
Save