diff --git a/Gemfile b/Gemfile index 6085dc1c..1353899d 100644 --- a/Gemfile +++ b/Gemfile @@ -221,6 +221,9 @@ gem 'dalli' gem 'icalendar' +# for making external requests easier +gem 'httparty' + # pagination gem 'pagy', '<4.0' diff --git a/Gemfile.lock b/Gemfile.lock index 9515baf8..68d93759 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -715,6 +715,7 @@ DEPENDENCIES guard-rspec haml-lint haml-rails + httparty icalendar iso-639 jquery-datatables diff --git a/app/controllers/conference_registrations_controller.rb b/app/controllers/conference_registrations_controller.rb index 8d4ae8a1..6a64ff8a 100644 --- a/app/controllers/conference_registrations_controller.rb +++ b/app/controllers/conference_registrations_controller.rb @@ -57,6 +57,8 @@ class ConferenceRegistrationsController < ApplicationController sign_in(@registration.user) end + MailblusterEditLeadJob.perform_later(@user, add_tags: ["snapcon-#{@conference.short_title}"]) + if @conference.tickets.visible.any? && !current_user.supports?(@conference) redirect_to conference_tickets_path(@conference.short_title), notice: 'You are now registered and will be receiving E-Mail notifications.' @@ -87,6 +89,7 @@ class ConferenceRegistrationsController < ApplicationController def destroy if @registration.destroy + MailblusterEditLeadJob.perform_later(@user, remove_tags: ["snapcon-#{@conference.short_title}"]) redirect_to root_path, notice: "You are not registered for #{@conference.title} anymore!" else diff --git a/app/jobs/mailbluster_create_lead_job.rb b/app/jobs/mailbluster_create_lead_job.rb new file mode 100644 index 00000000..701719d7 --- /dev/null +++ b/app/jobs/mailbluster_create_lead_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class MailblusterCreateLeadJob < ApplicationJob + queue_as :default + + def perform(user) + MailblusterManager.create_lead(user) + end +end diff --git a/app/jobs/mailbluster_delete_lead_job.rb b/app/jobs/mailbluster_delete_lead_job.rb new file mode 100644 index 00000000..387d1cc0 --- /dev/null +++ b/app/jobs/mailbluster_delete_lead_job.rb @@ -0,0 +1,7 @@ +class MailblusterDeleteLeadJob < ApplicationJob + queue_as :default + + def perform(user) + MailblusterManager.delete_lead(user) + end +end diff --git a/app/jobs/mailbluster_edit_lead_job.rb b/app/jobs/mailbluster_edit_lead_job.rb new file mode 100644 index 00000000..ff5d07ce --- /dev/null +++ b/app/jobs/mailbluster_edit_lead_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class MailblusterEditLeadJob < ApplicationJob + queue_as :default + + def perform(user, add_tags: [], remove_tags: [], old_email: nil) + MailblusterManager.edit_lead(user, add_tags: add_tags, remove_tags: remove_tags, old_email: old_email) + end +end diff --git a/app/models/concerns/track_saved_changes.rb b/app/models/concerns/track_saved_changes.rb new file mode 100644 index 00000000..db75e625 --- /dev/null +++ b/app/models/concerns/track_saved_changes.rb @@ -0,0 +1,48 @@ +# https://github.com/ccmcbeck/after-commit +module TrackSavedChanges + extend ActiveSupport::Concern + + included do + # expose the details if consumer wants to do more + # attr_reader :ts_saved_changes_history, :ts_saved_changes_unfiltered + after_initialize :ts_reset_saved_changes + after_save :ts_track_saved_changes + end + + # on initalize, but useful for fine grain control + def ts_reset_saved_changes + @ts_saved_changes_unfiltered = {} + @ts_saved_changes_history = [] + end + + # filter out any changes that result in the original value + def ts_saved_changes + @ts_saved_changes_unfiltered.reject { |_k, v| v[0] == v[1] } + end + + private + + # on save + def ts_track_saved_changes + # maintain an array of ActiveModel::Dirty.changes + @ts_saved_changes_history << previous_changes.dup + # accumulate the most recent changes + @ts_saved_changes_history.last.each_pair { |k, v| ts_track_saved_change k, v } + end + + # v is an an array of [prev, current] + def ts_track_saved_change(key, value) + if @ts_saved_changes_unfiltered.key? key + @ts_saved_changes_unfiltered[key][1] = ts_track_saved_value value[1] + else + @ts_saved_changes_unfiltered[key] = value.dup + end + end + + # type safe dup inspred by http://stackoverflow.com/a/20955038 + def ts_track_saved_value(value) + value.dup + rescue TypeError + value + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 955c76d1..ddcf63bb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -53,6 +53,7 @@ class UserDisabled < StandardError end class User < ApplicationRecord + include TrackSavedChanges rolify # prevent N+1 queries with has_cached_role? by preloading roles *always* default_scope { preload(:roles) } @@ -80,6 +81,12 @@ class User < ApplicationRecord after_save :touch_events + # Note that using after_create_commit and after_update_commit does not work. + # See https://github.com/CactusPuppy/snapcon/pull/43#discussion_r609458034 + after_commit :mailbluster_create_lead, on: :create + after_commit :mailbluster_delete_lead, on: :destroy + after_commit :mailbluster_update_lead, on: :update, if: ->(user){ ['name', 'email'].any? { |key| user.ts_saved_changes.key? key } } + # add scope scope :comment_notifiable, ->(conference) {joins(:roles).where('roles.name IN (?)', [:organizer, :cfp]).where('roles.resource_type = ? AND roles.resource_id = ?', 'Conference', conference.id)} @@ -361,12 +368,32 @@ class User < ApplicationRecord User.count == 1 && User.first.email == 'deleted@localhost.osem' end + # TODO: email_hash function for mailbluster + # def email_hash + # Digest::MD5.hexdigest user.email + # end + private def setup_role self.is_admin = true if User.empty? end + def mailbluster_create_lead + MailblusterCreateLeadJob.perform_later self + ts_reset_saved_changes + end + + def mailbluster_delete_lead + MailblusterDeleteLeadJob.perform_later email + ts_reset_saved_changes + end + + def mailbluster_update_lead + MailblusterEditLeadJob.perform_later(self, old_email: ts_saved_changes.fetch('email', [nil])[0]) + ts_reset_saved_changes + end + def touch_events event_users.each(&:touch) end diff --git a/app/services/mailbluster_manager.rb b/app/services/mailbluster_manager.rb new file mode 100644 index 00000000..d74ea76a --- /dev/null +++ b/app/services/mailbluster_manager.rb @@ -0,0 +1,40 @@ +class MailblusterManager + include HTTParty + base_uri 'https://api.mailbluster.com/api/leads/' + @auth_headers = { + headers: { + 'Content-Type' => 'application/json', + 'Authorization' => ENV['MAILBLUSTER_API_KEY'] + } + } + + def self.query_api(method, path, body: {}) + options = @auth_headers.merge(body: body.to_json) + send(method, path, options).parsed_response + end + + def self.create_lead(user) + query_api(:post, '/', body: { + 'email' => user.email, + 'firstName' => user.name, + 'overrideExisting' => true, + 'subscribed' => true, + 'tags' => [ENV['OSEM_NAME'] || 'snapcon'] + }) + end + + def self.edit_lead(user, add_tags: [], remove_tags: [], old_email: nil) + email_hash = Digest::MD5.hexdigest(old_email.presence || user.email) + query_api(:put, "/#{email_hash}", body: { + 'email' => user.email, + 'firstName' => user.name, + 'addTags' => add_tags, + 'removeTags' => remove_tags + }) + end + + def self.delete_lead(email) + email_hash = Digest::MD5.hexdigest email + query_api(:delete, "/#{email_hash}") + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index fcec2048..75af7370 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -80,8 +80,30 @@ FactoryBot.define do last_sign_in_at { Date.today } is_disabled { false } + after(:build) do |user| + url_mailbluster = 'https://api.mailbluster.com/api/leads/' + response_body = "{ + \"message\": \"Lead created\", + \"lead\": { + \"id\": 329395, + \"firstName\": \"#{user.name}\", + \"lastName\": \"\", + \"fullName\": \"#{user.name}\", + \"email\": \"#{user.email}\", + \"subscribed\": true, + \"tags\": [ + #{ENV['OSEM_NAME'] || 'snapcon'} + ], + } + }" + WebMock.stub_request(:post, url_mailbluster) + .to_return(body: response_body, status: 200) + end + # Called by every user creation + after(:create) do |user| user.is_admin = false + # save with bang cause we want change in DB and not just in object instance user.save! end diff --git a/spec/services/mailbluster_manager_spec.rb b/spec/services/mailbluster_manager_spec.rb new file mode 100644 index 00000000..52851089 --- /dev/null +++ b/spec/services/mailbluster_manager_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'webmock/rspec' + +describe MailblusterManager, type: :model do + let!(:user) { create(:user) } + + before(:each) do + WebMock.reset_executed_requests! + end + + url = 'https://api.mailbluster.com/api/leads/' + + describe 'query_api' do + it 'translates :get to a get request' do + stub_request(:get, url) + described_class.query_api(:get, '/') + + expect(WebMock).to have_requested(:get, url) + end + + it 'translates :post to a post request' do + stub_request(:post, url + 'path') + described_class.query_api(:post, '/path', body: { key: 'value' }) + + expect(WebMock).to have_requested(:post, url + 'path').with(body: { key: 'value' }) + end + end + + describe 'create_lead' do + it 'makes a post request to Mailbluster\'s API and gets the correct response' do + response_body = "{ + \"message\": \"Lead created\", + \"lead\": { + \"id\": 329395, + \"firstName\": \"#{user.name}\", + \"lastName\": \"\", + \"fullName\": \"#{user.name}\", + \"email\": \"#{user.email}\", + \"subscribed\": true, + \"tags\": [ + #{ENV['OSEM_NAME'] || 'snapcon'} + ], + } + }" + stub_request(:post, url) + .to_return(body: response_body, status: 200) + response = described_class.create_lead(user) + + expect(WebMock).to have_requested(:post, url).with(body: { + 'email': user.email, + 'firstName': user.name, + 'overrideExisting': true, + 'subscribed': true, + 'tags': [ENV['OSEM_NAME'] || 'snapcon'] + }.to_json) + expect(response).to eq(response_body) + end + end + + describe 'edit_lead' do + it 'makes a put request to Mailbluster\'s API to change the email and gets the correct response' do + response_body = "{ + \"message\": \"Lead updated\", + \"lead\": { + \"id\": 329395, + \"firstName\": \"#{user.name}\", + \"lastName\": \"\", + \"fullName\": \"#{user.name}\", + \"email\": \"#{user.email}\", + \"subscribed\": true, + \"tags\": [ + #{ENV['OSEM_NAME'] || 'snapcon'} + ], + } + }" + old_email = user.email + user.email = 'new@new.org' + user.save + stub_request(:put, url + Digest::MD5.hexdigest(old_email)) + .to_return(body: response_body, status: 200) + response = described_class.edit_lead(user, old_email: old_email) + + expect(WebMock).to have_requested(:put, url + Digest::MD5.hexdigest(old_email)).with(body: { + 'email': user.email, + 'firstName': user.name, + 'addTags': [], + 'removeTags': [] + }.to_json) + expect(response).to eq(response_body) + end + + it 'makes a put request to Mailbluster\'s API to add a tag and gets the correct response' do + response_body = "{ + \"message\": \"Lead updated\", + \"lead\": { + \"id\": 329395, + \"firstName\": \"#{user.name}\", + \"lastName\": \"\", + \"fullName\": \"#{user.name}\", + \"email\": \"#{user.email}\", + \"subscribed\": true, + \"tags\": [ + #{ENV['OSEM_NAME'] || 'snapcon'}, '2021' + ], + } + }" + stub_request(:put, url + Digest::MD5.hexdigest(user.email)) + .to_return(body: response_body, status: 200) + add_tags = ['2021'] + response = described_class.edit_lead(user, add_tags: add_tags) + + expect(WebMock).to have_requested(:put, url + Digest::MD5.hexdigest(user.email)).with(body: { + 'email': user.email, + 'firstName': user.name, + 'addTags': add_tags, + 'removeTags': [] + }.to_json) + expect(response).to eq(response_body) + end + end + + describe 'delete_lead' do + it 'correctly requests the right URL and gets a valid response' do + email_hash = Digest::MD5.hexdigest user.email + response_body = "{ + \"message\":\"Lead deleted\", + \"leadHash\":\"#{email_hash}\" + }" + lead_url = url + email_hash.to_s + stub_request(:delete, lead_url) + .to_return(body: response_body) + response = described_class.delete_lead(user.email) + + expect(WebMock).to have_requested(:delete, lead_url) + expect(response).to eq(response_body) + end + end +end diff --git a/spec/support/external_request.rb b/spec/support/external_request.rb index c968acd3..fbedd9e3 100644 --- a/spec/support/external_request.rb +++ b/spec/support/external_request.rb @@ -11,6 +11,7 @@ RSpec.configure do |config| config.before(:each) do mock_commercial_request mock_image_request + mock_default_mailbluster end end @@ -39,3 +40,7 @@ def mock_image_request WebMock.stub_request(:post, 'https://api.cloudinary.com/v1_1/snapcon/image/destroy') .to_return(status: 200, body: {}.to_json, headers: {}) end + +def mock_default_mailbluster + WebMock.stub_request(:any, /api.mailbluster.com/) +end