Merge pull request #43 from CactusPuppy/176895554/mailbluster

Integrate Snapcon with Mailbluster
This commit is contained in:
CactusPuppy 2021-04-12 15:46:25 -07:00 committed by GitHub
commit db8cb3ea1a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 314 additions and 0 deletions

View file

@ -221,6 +221,9 @@ gem 'dalli'
gem 'icalendar' gem 'icalendar'
# for making external requests easier
gem 'httparty'
# pagination # pagination
gem 'pagy', '<4.0' gem 'pagy', '<4.0'

View file

@ -715,6 +715,7 @@ DEPENDENCIES
guard-rspec guard-rspec
haml-lint haml-lint
haml-rails haml-rails
httparty
icalendar icalendar
iso-639 iso-639
jquery-datatables jquery-datatables

View file

@ -57,6 +57,8 @@ class ConferenceRegistrationsController < ApplicationController
sign_in(@registration.user) sign_in(@registration.user)
end end
MailblusterEditLeadJob.perform_later(@user, add_tags: ["snapcon-#{@conference.short_title}"])
if @conference.tickets.visible.any? && !current_user.supports?(@conference) if @conference.tickets.visible.any? && !current_user.supports?(@conference)
redirect_to conference_tickets_path(@conference.short_title), redirect_to conference_tickets_path(@conference.short_title),
notice: 'You are now registered and will be receiving E-Mail notifications.' notice: 'You are now registered and will be receiving E-Mail notifications.'
@ -87,6 +89,7 @@ class ConferenceRegistrationsController < ApplicationController
def destroy def destroy
if @registration.destroy if @registration.destroy
MailblusterEditLeadJob.perform_later(@user, remove_tags: ["snapcon-#{@conference.short_title}"])
redirect_to root_path, redirect_to root_path,
notice: "You are not registered for #{@conference.title} anymore!" notice: "You are not registered for #{@conference.title} anymore!"
else else

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class MailblusterCreateLeadJob < ApplicationJob
queue_as :default
def perform(user)
MailblusterManager.create_lead(user)
end
end

View file

@ -0,0 +1,7 @@
class MailblusterDeleteLeadJob < ApplicationJob
queue_as :default
def perform(user)
MailblusterManager.delete_lead(user)
end
end

View file

@ -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

View file

@ -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

View file

@ -53,6 +53,7 @@ class UserDisabled < StandardError
end end
class User < ApplicationRecord class User < ApplicationRecord
include TrackSavedChanges
rolify rolify
# prevent N+1 queries with has_cached_role? by preloading roles *always* # prevent N+1 queries with has_cached_role? by preloading roles *always*
default_scope { preload(:roles) } default_scope { preload(:roles) }
@ -80,6 +81,12 @@ class User < ApplicationRecord
after_save :touch_events 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 # 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)} 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' User.count == 1 && User.first.email == 'deleted@localhost.osem'
end end
# TODO: email_hash function for mailbluster
# def email_hash
# Digest::MD5.hexdigest user.email
# end
private private
def setup_role def setup_role
self.is_admin = true if User.empty? self.is_admin = true if User.empty?
end 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 def touch_events
event_users.each(&:touch) event_users.each(&:touch)
end end

View file

@ -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

View file

@ -80,8 +80,30 @@ FactoryBot.define do
last_sign_in_at { Date.today } last_sign_in_at { Date.today }
is_disabled { false } 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| after(:create) do |user|
user.is_admin = false user.is_admin = false
# save with bang cause we want change in DB and not just in object instance # save with bang cause we want change in DB and not just in object instance
user.save! user.save!
end end

View file

@ -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

View file

@ -11,6 +11,7 @@ RSpec.configure do |config|
config.before(:each) do config.before(:each) do
mock_commercial_request mock_commercial_request
mock_image_request mock_image_request
mock_default_mailbluster
end end
end end
@ -39,3 +40,7 @@ def mock_image_request
WebMock.stub_request(:post, 'https://api.cloudinary.com/v1_1/snapcon/image/destroy') WebMock.stub_request(:post, 'https://api.cloudinary.com/v1_1/snapcon/image/destroy')
.to_return(status: 200, body: {}.to_json, headers: {}) .to_return(status: 200, body: {}.to_json, headers: {})
end end
def mock_default_mailbluster
WebMock.stub_request(:any, /api.mailbluster.com/)
end