diff --git a/admin/Gemfile b/admin/Gemfile index 5ec8a3411..5fea93d71 100644 --- a/admin/Gemfile +++ b/admin/Gemfile @@ -65,10 +65,11 @@ gem 'logging-rails', :require => 'logging/rails' gem 'pg_migrate' gem 'ruby-protocol-buffers', '1.2.2' -gem 'sendgrid', '1.1.0' +gem 'sendgrid', '1.2.0' gem 'geokit-rails' gem 'postgres_ext', '1.0.0' +gem 'resque_mailer' group :libv8 do gem 'libv8', "~> 3.11.8" @@ -106,6 +107,7 @@ group :development, :test do gem 'factory_girl_rails', '4.1.0' gem 'database_cleaner', '0.7.0' gem 'launchy' + gem 'faker' end group :test do diff --git a/admin/app/admin/email_batch.rb b/admin/app/admin/email_batch.rb new file mode 100644 index 000000000..3f15aadd3 --- /dev/null +++ b/admin/app/admin/email_batch.rb @@ -0,0 +1,106 @@ +ActiveAdmin.register JamRuby::EmailBatch, :as => 'Batch Emails' do + + menu :label => 'Emails' + + config.sort_order = 'updated_at DESC' + config.batch_actions = false + # config.clear_action_items! + config.filters = false + + form :partial => 'form' + + index do + column 'Subject' do |pp| pp.subject end + column 'Updated' do |pp| pp.updated_at end + column 'From' do |pp| pp.from_email end + column 'Status' do |pp| pp.aasm_state end + column 'Test Emails' do |pp| pp.test_emails end + column 'Email Count' do |pp| pp.qualified_count end + column 'Send Count' do |pp| pp.sent_count end + column 'Started' do |pp| pp.started_at end + column 'Completed' do |pp| pp.completed_at end + column 'Send Test' do |pp| + link_to("Test Batch (#{pp.test_count})", + batch_test_admin_batch_email_path(pp.id), + :confirm => "Run test batch with #{pp.test_count} emails?") + end + column 'Send Live' do |pp| + link_to("Live Batch (#{User.email_opt_in.count})", + batch_send_admin_batch_email_path(pp.id), + :confirm => "Run LIVE batch with #{User.email_opt_in.count} emails?") + end + + default_actions + end + + action_item :only => :show do + link_to("Send Test Batch (#{resource.test_count})", + batch_test_admin_batch_email_path(resource.id), + :confirm => "Run test batch with #{resource.test_count} emails?") + end + + action_item :only => :show do + link_to("Send Live Batch (#{User.email_opt_in.count})", + batch_send_admin_batch_email_path(resource.id), + :confirm => "Run LIVE batch with #{User.email_opt_in.count} emails?") + end + + show :title => 'Batch Email' do |obj| + panel 'Email Contents' do + attributes_table_for obj do + row 'From' do |obj| obj.from_email end + row 'Test Emails' do |obj| obj.test_emails end + row 'Subject' do |obj| obj.subject end + row 'Body' do |obj| obj.body end + end + end + columns do + column do + panel 'Sending Parameters' do + attributes_table_for obj do + row 'State' do |obj| obj.aasm_state end + row 'User Count' do |obj| + obj.qualified_count ? obj.qualified_count : User.email_opt_in.count + end + row 'Sent Count' do |obj| obj.sent_count end + row 'Started' do |obj| obj.started_at end + row 'Completed' do |obj| obj.completed_at end + row 'Updated' do |obj| obj.updated_at end + end + end + end + column do + panel 'Send Results' do + end + end + end + end + + controller do + + def create + batch = EmailBatch.create_with_params(params[:jam_ruby_email_batch]) + redirect_to admin_batch_email_path(batch.id) + # redirect_to admin_batch_emails_path + end + + def update + resource.update_with_conflict_validation(params[:jam_ruby_email_batch]) + redirect_to admin_batch_email_path(resource.id) + end + + end + + member_action :batch_test, :method => :get do + batch = EmailBatch.find(params[:id]) + batch.send_test_batch + redirect_to admin_batch_email_path(batch.id) + end + + member_action :batch_send, :method => :get do + batch = EmailBatch.find(params[:id]) + batch.deliver_batch + redirect_to admin_batch_email_path(batch.id) + end + +end diff --git a/admin/app/views/admin/batch_emails/_form.html.erb b/admin/app/views/admin/batch_emails/_form.html.erb new file mode 100644 index 000000000..a9c83f836 --- /dev/null +++ b/admin/app/views/admin/batch_emails/_form.html.erb @@ -0,0 +1,9 @@ +<%= semantic_form_for([:admin, resource], :url => resource.new_record? ? admin_batch_emails_path : "/admin/batch_emails/#{resource.id}") do |f| %> + <%= f.inputs do %> + <%= f.input(:from_email, :label => "From Email", :input_html => {:maxlength => 64}) %> + <%= f.input(:subject, :label => "Subject", :input_html => {:maxlength => 128}) %> + <%= f.input(:test_emails, :label => "Test Emails", :input_html => {:maxlength => 1024, :size => '3x3'}) %> + <%= f.input(:body, :label => "Body", :input_html => {:maxlength => 3096, :size => '10x20'}) %> + <% end %> + <%= f.actions %> +<% end %> diff --git a/admin/config/application.rb b/admin/config/application.rb index 0ae29cd8e..36a91e1a5 100644 --- a/admin/config/application.rb +++ b/admin/config/application.rb @@ -119,5 +119,7 @@ module JamAdmin config.twitter_app_secret = ENV['TWITTER_APP_SECRET'] || 'Azcy3QqfzYzn2fsojFPYXcn72yfwa0vG6wWDrZ3KT8' config.ffmpeg_path = ENV['FFMPEG_PATH'] || (File.exist?('/usr/local/bin/ffmpeg') ? '/usr/local/bin/ffmpeg' : '/usr/bin/ffmpeg') + + config.max_audio_downloads = 100 end end diff --git a/db/manifest b/db/manifest index 9b732d4fe..aba246d9b 100755 --- a/db/manifest +++ b/db/manifest @@ -137,4 +137,6 @@ cascading_delete_constraints_for_release.sql events_social_description.sql fix_broken_cities.sql notifications_with_text.sql - +notification_seen_at.sql +order_event_session.sql +emails.sql diff --git a/db/up/emails.sql b/db/up/emails.sql new file mode 100644 index 000000000..86f8b4f6f --- /dev/null +++ b/db/up/emails.sql @@ -0,0 +1,38 @@ +CREATE TABLE email_batches ( + id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), + subject VARCHAR(256) NOT NULL, + body TEXT NOT NULL, + from_email VARCHAR(64) NOT NULL default 'support@jamkazam.com', + + aasm_state VARCHAR(32) NOT NULL default 'pending', + + test_emails TEXT NOT NULL default '', + + qualified_count INTEGER NOT NULL default 0, + sent_count INTEGER NOT NULL default 0, + + lock_version INTEGER, + + started_at TIMESTAMP, + completed_at TIMESTAMP, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CREATE TABLE email_batch_results ( +-- id VARCHAR(64) PRIMARY KEY DEFAULT uuid_generate_v4(), +-- email_batch_id VARCHAR(64) REFERENCES email_batches(id) ON DELETE CASCADE, +-- user_id VARCHAR(64) REFERENCES users(id) ON DELETE CASCADE, + +-- error_type VARCHAR(32), +-- email_address VARCHAR(256), + +-- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, +-- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +-- ); + +-- ALTER TABLE email_batch_results ADD CONSTRAINT email_batch_uniqkey UNIQUE (email_batch_id); +-- ALTER TABLE email_batch_results ADD CONSTRAINT email_user_uniqkey UNIQUE (user_id); + +ALTER TABLE users ALTER COLUMN subscribe_email SET DEFAULT true; diff --git a/db/up/notification_seen_at.sql b/db/up/notification_seen_at.sql new file mode 100644 index 000000000..67b29ddd8 --- /dev/null +++ b/db/up/notification_seen_at.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN notification_seen_at TIMESTAMP; \ No newline at end of file diff --git a/db/up/order_event_session.sql b/db/up/order_event_session.sql new file mode 100644 index 000000000..ba36f8f94 --- /dev/null +++ b/db/up/order_event_session.sql @@ -0,0 +1 @@ +ALTER TABLE event_sessions ADD COLUMN ordinal INTEGER; \ No newline at end of file diff --git a/pb/src/client_container.proto b/pb/src/client_container.proto index 4ae83c193..1df3bebf8 100644 --- a/pb/src/client_container.proto +++ b/pb/src/client_container.proto @@ -472,7 +472,8 @@ message TestClientMessage { // sent from client to server periodically to let server track if the client is truly alive and avoid TCP timeout scenarios // the server will send a HeartbeatAck in response to this message Heartbeat { - + optional string notification_seen = 1; + optional string notification_seen_at = 2; } // target: client diff --git a/ruby/Gemfile b/ruby/Gemfile index 7ba18443c..cd4bca5ec 100644 --- a/ruby/Gemfile +++ b/ruby/Gemfile @@ -27,7 +27,7 @@ gem 'eventmachine', '1.0.3' gem 'amqp', '1.0.2' gem 'will_paginate' gem 'actionmailer', '3.2.13' -gem 'sendgrid' +gem 'sendgrid', '1.2.0' gem 'aws-sdk', '1.29.1' gem 'carrierwave' gem 'aasm', '3.0.16' @@ -40,6 +40,7 @@ gem 'resque' gem 'resque-retry' gem 'resque-failed-job-mailer' #, :path => "/Users/seth/workspace/resque_failed_job_mailer" gem 'resque-lonely_job', '~> 1.0.0' +gem 'resque_mailer' gem 'oj' gem 'builder' gem 'fog' diff --git a/ruby/lib/jam_ruby.rb b/ruby/lib/jam_ruby.rb index da72d911b..b6f17a3a4 100755 --- a/ruby/lib/jam_ruby.rb +++ b/ruby/lib/jam_ruby.rb @@ -16,6 +16,7 @@ require "geokit-rails" require "postgres_ext" require 'builder' require 'cgi' +require 'resque_mailer' require "jam_ruby/constants/limits" require "jam_ruby/constants/notification_types" @@ -132,6 +133,9 @@ require "jam_ruby/models/playable_play" require "jam_ruby/models/country" require "jam_ruby/models/region" require "jam_ruby/models/city" +require "jam_ruby/models/email_batch" +require "jam_ruby/app/mailers/async_mailer" +require "jam_ruby/app/mailers/batch_mailer" include Jampb diff --git a/ruby/lib/jam_ruby/app/mailers/async_mailer.rb b/ruby/lib/jam_ruby/app/mailers/async_mailer.rb new file mode 100644 index 000000000..4cbad60e4 --- /dev/null +++ b/ruby/lib/jam_ruby/app/mailers/async_mailer.rb @@ -0,0 +1,8 @@ +require 'resque_mailer' + +module JamRuby + class AsyncMailer < ActionMailer::Base + include SendGrid + include Resque::Mailer + end +end diff --git a/ruby/lib/jam_ruby/app/mailers/batch_mailer.rb b/ruby/lib/jam_ruby/app/mailers/batch_mailer.rb new file mode 100644 index 000000000..11e21efd2 --- /dev/null +++ b/ruby/lib/jam_ruby/app/mailers/batch_mailer.rb @@ -0,0 +1,38 @@ +module JamRuby + class BatchMailer < JamRuby::AsyncMailer + layout "user_mailer" + + sendgrid_category :use_subject_lines + sendgrid_unique_args :env => Environment.mode + + def _send_batch(batch, users) + @batch_body = batch.body + emails = users.map(&:email) + + sendgrid_recipients(emails) + sendgrid_substitute(EmailBatch::VAR_FIRST_NAME, users.map(&:first_name)) + + batch.did_send(emails) + + mail(:to => emails, + :from => batch.from_email, + :subject => batch.subject) do |format| + format.text + format.html + end + end + + def send_batch_email(batch_id, user_ids) + users = User.find_all_by_id(user_ids) + batch = EmailBatch.where(:id => batch_id).limit(1).first + self._send_batch(batch, users) + end + + def send_batch_email_test(batch_id) + batch = EmailBatch.where(:id => batch_id).limit(1).first + users = batch.test_users + self._send_batch(batch, users) + end + + end +end diff --git a/ruby/lib/jam_ruby/app/views/jam_ruby/batch_mailer/send_batch_email.html.erb b/ruby/lib/jam_ruby/app/views/jam_ruby/batch_mailer/send_batch_email.html.erb new file mode 100644 index 000000000..31bd20e21 --- /dev/null +++ b/ruby/lib/jam_ruby/app/views/jam_ruby/batch_mailer/send_batch_email.html.erb @@ -0,0 +1 @@ +<%= @body %> diff --git a/ruby/lib/jam_ruby/app/views/jam_ruby/batch_mailer/send_batch_email.text.erb b/ruby/lib/jam_ruby/app/views/jam_ruby/batch_mailer/send_batch_email.text.erb new file mode 100644 index 000000000..31bd20e21 --- /dev/null +++ b/ruby/lib/jam_ruby/app/views/jam_ruby/batch_mailer/send_batch_email.text.erb @@ -0,0 +1 @@ +<%= @body %> diff --git a/ruby/lib/jam_ruby/app/views/jam_ruby/batch_mailer/send_batch_email_test.html.erb b/ruby/lib/jam_ruby/app/views/jam_ruby/batch_mailer/send_batch_email_test.html.erb new file mode 120000 index 000000000..f14e9223c --- /dev/null +++ b/ruby/lib/jam_ruby/app/views/jam_ruby/batch_mailer/send_batch_email_test.html.erb @@ -0,0 +1 @@ +send_batch_email.html.erb \ No newline at end of file diff --git a/ruby/lib/jam_ruby/app/views/jam_ruby/batch_mailer/send_batch_email_test.text.erb b/ruby/lib/jam_ruby/app/views/jam_ruby/batch_mailer/send_batch_email_test.text.erb new file mode 120000 index 000000000..2a1e564e8 --- /dev/null +++ b/ruby/lib/jam_ruby/app/views/jam_ruby/batch_mailer/send_batch_email_test.text.erb @@ -0,0 +1 @@ +send_batch_email.text.erb \ No newline at end of file diff --git a/ruby/lib/jam_ruby/app/views/jam_ruby/user_mailer/password_changed.html.erb b/ruby/lib/jam_ruby/app/views/jam_ruby/user_mailer/password_changed.html.erb index a140d13fe..534066c5a 100644 --- a/ruby/lib/jam_ruby/app/views/jam_ruby/user_mailer/password_changed.html.erb +++ b/ruby/lib/jam_ruby/app/views/jam_ruby/user_mailer/password_changed.html.erb @@ -1,3 +1,3 @@ <% provide(:title, 'Jamkazam Password Changed') %> -You just changed your password at Jamkazam. +You just changed your password at JamKazam. diff --git a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb index 6fcd69d53..1198a26f4 100644 --- a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb +++ b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.html.erb @@ -24,13 +24,11 @@

<%= yield(:title) %>

-

<%= yield %>

+

<%= @batch_body ? @batch_body.html_safe : yield %>


- - <% unless @suppress_user_has_account_footer == true %> @@ -39,8 +37,8 @@ +

This email was sent to you because you have an account at JamKazam.  Click here to unsubscribe and update your profile settings.

-

This email was sent to you because you have an account at JamKazam. diff --git a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb index 8bd3c7483..5c8262f63 100644 --- a/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb +++ b/ruby/lib/jam_ruby/app/views/layouts/user_mailer.text.erb @@ -1,8 +1,11 @@ -<%= yield %> - +<% if @batch_body %> + <%= Nokogiri::HTML(@batch_body).text %> +<% else %> + <%= yield %> +<% end %> <% unless @suppress_user_has_account_footer == true %> -This email was sent to you because you have an account at JamKazam / http://www.jamkazam.com. +This email was sent to you because you have an account at JamKazam / http://www.jamkazam.com. Visit your profile page to unsubscribe: http://www.jamkazam.com/client#/account/profile. <% end %> Copyright <%= Time.now.year %> JamKazam, Inc. All rights reserved. diff --git a/ruby/lib/jam_ruby/models/email_batch.rb b/ruby/lib/jam_ruby/models/email_batch.rb new file mode 100644 index 000000000..a2fd36387 --- /dev/null +++ b/ruby/lib/jam_ruby/models/email_batch.rb @@ -0,0 +1,142 @@ +module JamRuby + class EmailBatch < ActiveRecord::Base + self.table_name = "email_batches" + + attr_accessible :from_email, :subject, :test_emails, :body + attr_accessible :lock_version, :qualified_count, :sent_count, :started_at, :completed_at + + VAR_FIRST_NAME = '@FIRSTNAME' + VAR_LAST_NAME = '@LASTNAME' + + DEFAULT_SENDER = "support@jamkazam.com" + + include AASM + aasm do + state :pending, :initial => true + state :testing + state :tested + state :batching + state :batched + state :disabled + + event :enable do + transitions :from => :disabled, :to => :pending + end + event :reset do + transitions :from => [:disabled, :testing, :tested, :batching, :batched, :pending], :to => :pending + end + event :do_test_run, :before => :running_tests do + transitions :from => [:pending, :tested, :batched], :to => :testing + end + event :did_test_run, :after => :ran_tests do + transitions :from => :testing, :to => :tested + end + event :do_batch_run, :before => :running_batch do + transitions :from => [:tested, :pending, :batched], :to => :batching + end + event :did_batch_run, :after => :ran_batch do + transitions :from => :batching, :to => :batched + end + event :disable do + transitions :from => [:pending, :tested, :batched], :to => :disabled + end + end + + # has_many :email_batch_results, :class_name => 'JamRuby::EmailBatchResult' + + def self.create_with_params(params) + obj = self.new + params.each { |kk,vv| vv.strip! } + obj.update_with_conflict_validation(params) + obj + end + + def deliver_batch + self.perform_event('do_batch_run!') + User.email_opt_in.find_in_batches(batch_size: 1000) do |users| + BatchMailer.send_batch_email(self.id, users.map(&:id)).deliver + end + end + + def test_count + self.test_emails.split(',').count + end + + def test_users + self.test_emails.split(',').collect do |ee| + ee.strip! + uu = User.new + uu.email = ee + uu.first_name = ee.match(/^(.*)@/)[1].to_s + uu.last_name = 'Test' + uu + end + end + + def send_test_batch + self.perform_event('do_test_run!') + BatchMailer.send_batch_email_test(self.id).deliver + end + + def merged_body(user) + body.gsub(VAR_FIRST_NAME, user.first_name).gsub(VAR_LAST_NAME, user.last_name) + end + + def did_send(emails) + self.update_with_conflict_validation({ :sent_count => self.sent_count + emails.size }) + + if self.sent_count >= self.qualified_count + if batching? + self.perform_event('did_batch_run!') + elsif testing? + self.perform_event('did_test_run!') + end + end + end + + def perform_event(event_name) + num_try = 0 + self.send(event_name) + rescue ActiveRecord::StaleObjectError + num_try += 1 + if 5 > num_try + self.reload + retry + end + end + + def update_with_conflict_validation(*args) + num_try = 0 + update_attributes(*args) + rescue ActiveRecord::StaleObjectError + num_try += 1 + if 5 > num_try + self.reload + retry + end + end + + def running_batch + self.update_with_conflict_validation({:qualified_count => User.email_opt_in.count, + :sent_count => 0, + :started_at => Time.now + }) + end + + def running_tests + self.update_with_conflict_validation({:qualified_count => self.test_count, + :sent_count => 0, + :started_at => Time.now + }) + end + + def ran_tests + self.update_with_conflict_validation({ :completed_at => Time.now }) + end + + def ran_batch + self.update_with_conflict_validation({ :completed_at => Time.now }) + end + + end +end diff --git a/ruby/lib/jam_ruby/models/event_session.rb b/ruby/lib/jam_ruby/models/event_session.rb index 7385de68d..49329712c 100644 --- a/ruby/lib/jam_ruby/models/event_session.rb +++ b/ruby/lib/jam_ruby/models/event_session.rb @@ -1,6 +1,6 @@ class JamRuby::EventSession < ActiveRecord::Base - attr_accessible :event_id, :user_id, :band_id, :starts_at, :ends_at, :pinned_state, :position, :img_url, :img_width, :img_height, as: :admin + attr_accessible :event_id, :user_id, :band_id, :starts_at, :ends_at, :pinned_state, :position, :img_url, :img_width, :img_height, :ordinal, as: :admin belongs_to :user, class_name: 'JamRuby::User' belongs_to :band, class_name: 'JamRuby::Band' @@ -13,6 +13,48 @@ class JamRuby::EventSession < ActiveRecord::Base before_validation :sanitize_active_admin + def has_public_mixed_recordings? + public_mixed_recordings.length > 0 + end + + def public_mixed_recordings + recordings.select { |recording| recording if recording.has_mix? && recording.is_public? } + end + + def recordings + recordings=[] + + sessions.each do |session_history| + recordings = recordings + session_history.recordings + end + + recordings.sort! do |x, y| + x.candidate_claimed_recording.name <=> y.candidate_claimed_recording.name + end + + recordings + end + + # ideally this is based on some proper association with the event, not such a slushy time grab + def sessions + if ready_display + query = MusicSessionHistory.where(fan_access: true).where(created_at: (self.starts_at - 12.hours)..(self.ends_at + 12.hours)) + if self.user_id + query = query.where(user_id: self.user_id) + elsif self.band_id + query = query.where(band_id: self.band_id) + else + raise 'invalid state in event_session_button' + end + query + else + [] + end + end + + def ready_display + self.starts_at && self.ends_at && (self.user_id || self.band_id) + end def sanitize_active_admin self.img_url = nil if self.img_url == '' self.user_id = nil if self.user_id == '' diff --git a/ruby/lib/jam_ruby/models/music_session_history.rb b/ruby/lib/jam_ruby/models/music_session_history.rb index 089969434..5e3637284 100644 --- a/ruby/lib/jam_ruby/models/music_session_history.rb +++ b/ruby/lib/jam_ruby/models/music_session_history.rb @@ -157,6 +157,10 @@ module JamRuby music_session && music_session.mount end + def recordings + Recording.where(music_session_id: self.id) + end + def end_history self.update_attribute(:session_removed_at, Time.now) diff --git a/ruby/lib/jam_ruby/models/recording.rb b/ruby/lib/jam_ruby/models/recording.rb index 570734180..645ccf6b8 100644 --- a/ruby/lib/jam_ruby/models/recording.rb +++ b/ruby/lib/jam_ruby/models/recording.rb @@ -132,7 +132,7 @@ module JamRuby end def has_access?(user) - return users.exists?(user) + users.exists?(user) end # Start recording a session. @@ -342,6 +342,9 @@ module JamRuby save end + def is_public? + claimed_recordings.where(is_public: true).length > 0 + end # meant to be used as a way to 'pluck' a claimed_recording appropriate for user. def candidate_claimed_recording diff --git a/ruby/lib/jam_ruby/models/user.rb b/ruby/lib/jam_ruby/models/user.rb index 93f73d829..2ac2ac6a8 100644 --- a/ruby/lib/jam_ruby/models/user.rb +++ b/ruby/lib/jam_ruby/models/user.rb @@ -5,8 +5,9 @@ module JamRuby #devise: for later: :trackable - devise :database_authenticatable, - :recoverable, :rememberable + VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i + + devise :database_authenticatable, :recoverable, :rememberable include Geokit::ActsAsMappable::Glue unless defined?(acts_as_mappable) acts_as_mappable @@ -107,8 +108,7 @@ module JamRuby validates :first_name, presence: true, length: {maximum: 50}, no_profanity: true validates :last_name, presence: true, length: {maximum: 50}, no_profanity: true validates :biography, length: {maximum: 4000}, no_profanity: true - VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i - validates :email, presence: true, format: {with: VALID_EMAIL_REGEX} + validates :email, presence: true, format: {with: VALID_EMAIL_REGEX} validates :update_email, presence: true, format: {with: VALID_EMAIL_REGEX}, :if => :updating_email validates_length_of :password, minimum: 6, maximum: 100, :if => :should_validate_password? @@ -132,6 +132,7 @@ module JamRuby scope :fans, where(:musician => false) scope :geocoded_users, where(['lat IS NOT NULL AND lng IS NOT NULL']) scope :musicians_geocoded, musicians.geocoded_users + scope :email_opt_in, where(:subscribe_email => true) def user_progression_fields @user_progression_fields ||= Set.new ["first_downloaded_client_at", "first_ran_client_at", "first_music_session_at", "first_real_music_session_at", "first_good_music_session_at", "first_certified_gear_at", "first_invited_at", "first_friended_at", "first_recording_at", "first_social_promoted_at" ] @@ -291,7 +292,33 @@ module JamRuby recordings.concat(msh) recordings.sort! {|a,b| b.created_at <=> a.created_at}.first(5) end - + + # returns the # of new notifications + def new_notifications + search = Notification.select('id').where(target_user_id: self.id) + search = search.where('created_at > ?', self.notification_seen_at) if self.notification_seen_at + search.count + end + + # the user can pass in a timestamp string, or the keyword 'LATEST' + # if LATEST is specified, we'll use the latest_notification as the timestamp + # if not, just use seen as-is + def update_notification_seen_at seen + new_latest_seen = nil + if seen == 'LATEST' + latest = self.latest_notification + new_latest_seen = latest.created_at if latest + else + new_latest_seen = seen + end + + self.notification_seen_at = new_latest_seen + end + + def latest_notification + Notification.select('created_at').where(target_user_id: id).limit(1).order('created_at DESC').first + end + def confirm_email! self.email_confirmed = true end @@ -378,7 +405,7 @@ module JamRuby def self.reset_password(email, base_uri) user = User.where("email ILIKE ?", email).first - raise JamRuby::JamArgumentError if user.nil? + raise JamRuby::JamArgumentError.new('unknown email', :email) if user.nil? user.reset_password_token = SecureRandom.urlsafe_base64 user.reset_password_token_created = Time.now diff --git a/ruby/spec/factories.rb b/ruby/spec/factories.rb index 80d337abc..0c32a5dbb 100644 --- a/ruby/spec/factories.rb +++ b/ruby/spec/factories.rb @@ -429,6 +429,12 @@ FactoryGirl.define do factory :event_session, :class => JamRuby::EventSession do end + factory :email_batch, :class => JamRuby::EmailBatch do + subject Faker::Lorem.sentence + body "#{JamRuby::EmailBatch::VAR_FIRST_NAME} " + Faker::Lorem.paragraphs(3).join("\n") + test_emails 4.times.collect { Faker::Internet.safe_email }.join(',') + end + factory :notification, :class => JamRuby::Notification do factory :notification_text_message do diff --git a/ruby/spec/jam_ruby/models/email_batch_spec.rb b/ruby/spec/jam_ruby/models/email_batch_spec.rb new file mode 100644 index 000000000..e3b14c32d --- /dev/null +++ b/ruby/spec/jam_ruby/models/email_batch_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe EmailBatch do + let (:email_batch) { FactoryGirl.create(:email_batch) } + + before(:each) do + BatchMailer.deliveries.clear + end + + it 'has test emails setup' do + expect(email_batch.test_emails.present?).to be true + expect(email_batch.pending?).to be true + + users = email_batch.test_users + expect(email_batch.test_count).to eq(users.count) + end + +end diff --git a/ruby/spec/jam_ruby/models/track_spec.rb b/ruby/spec/jam_ruby/models/track_spec.rb index 781456720..be63c781b 100644 --- a/ruby/spec/jam_ruby/models/track_spec.rb +++ b/ruby/spec/jam_ruby/models/track_spec.rb @@ -116,8 +116,8 @@ describe Track do tracks = Track.sync(connection.client_id, [{:id => track.id, :client_track_id => track.client_track_id, :sound => track.sound, :instrument_id => track.instrument_id}]) tracks.length.should == 1 found = tracks[0] - found.id.should == track.id - found.updated_at.should == track.updated_at + expect(found.id).to eq track.id + expect(found.updated_at.to_i).to eq track.updated_at.to_i end end end \ No newline at end of file diff --git a/ruby/spec/jam_ruby/models/user_spec.rb b/ruby/spec/jam_ruby/models/user_spec.rb index f52876ce0..744aef38d 100644 --- a/ruby/spec/jam_ruby/models/user_spec.rb +++ b/ruby/spec/jam_ruby/models/user_spec.rb @@ -195,7 +195,7 @@ describe User do end it "fails if the provided email address is unrecognized" do - expect { User.reset_password("invalidemail@invalid.com", RESET_PASSWORD_URL) }.to raise_error + expect { User.reset_password("invalidemail@invalid.com", RESET_PASSWORD_URL) }.to raise_error(JamRuby::JamArgumentError) end it "assigns a reset_token and reset_token_created on reset" do diff --git a/ruby/spec/mailers/batch_mailer_spec.rb b/ruby/spec/mailers/batch_mailer_spec.rb new file mode 100644 index 000000000..a1a216607 --- /dev/null +++ b/ruby/spec/mailers/batch_mailer_spec.rb @@ -0,0 +1,26 @@ +require "spec_helper" + +describe BatchMailer do + + describe "should send test emails" do + BatchMailer.deliveries.clear + + let (:mail) { BatchMailer.deliveries[0] } + + batch = FactoryGirl.create(:email_batch) + batch.update_attribute(:test_emails, "jonathan@jamkazam.com") + batch.send_test_batch + + it { BatchMailer.deliveries.length.should == 1 } + + it { mail['from'].to_s.should == EmailBatch::DEFAULT_SENDER } + it { mail['to'].to_s.split(',')[0].should == batch.test_emails.split(',')[0] } + it { mail.subject.should == batch.subject } + + it { mail.multipart?.should == true } # because we send plain + html + it { mail.text_part.decode_body.should match(/#{Regexp.escape(batch.body)}/) } + + it { batch.testing?.should == true } + end + +end diff --git a/web/Gemfile b/web/Gemfile index 6ea68e5fb..f2cdab689 100644 --- a/web/Gemfile +++ b/web/Gemfile @@ -45,7 +45,7 @@ gem 'omniauth-twitter' gem 'omniauth-google-oauth2', '0.2.1' gem 'twitter' gem 'fb_graph', '2.5.9' -gem 'sendgrid', '1.1.0' +gem 'sendgrid', '1.2.0' gem 'recaptcha', '0.3.4' gem 'filepicker-rails', '0.1.0' gem 'aws-sdk', '1.29.1' @@ -67,6 +67,8 @@ gem 'resque-retry' gem 'resque-failed-job-mailer' gem 'resque-dynamic-queues' gem 'resque-lonely_job', '~> 1.0.0' +gem 'resque_mailer' + gem 'quiet_assets', :group => :development gem 'bugsnag' gem 'multi_json', '1.9.0' diff --git a/web/app/assets/javascripts/AAB_message_factory.js b/web/app/assets/javascripts/AAB_message_factory.js index 7f11e603d..04bd51344 100644 --- a/web/app/assets/javascripts/AAB_message_factory.js +++ b/web/app/assets/javascripts/AAB_message_factory.js @@ -99,8 +99,10 @@ }; // Heartbeat message - factory.heartbeat = function() { + factory.heartbeat = function(lastNotificationSeen, lastNotificationSeenAt) { var data = {}; + data.notification_seen = lastNotificationSeen; + data.notification_seen_at = lastNotificationSeenAt; return client_container(msg.HEARTBEAT, route_to.SERVER, data); }; diff --git a/web/app/assets/javascripts/application.js b/web/app/assets/javascripts/application.js index 68db544ad..ec1dee5c6 100644 --- a/web/app/assets/javascripts/application.js +++ b/web/app/assets/javascripts/application.js @@ -28,6 +28,9 @@ //= require jquery.infinitescroll //= require jquery.hoverIntent //= require jquery.dotdotdot +//= require jquery.pulse +//= require jquery.browser +//= require jquery.custom-protocol //= require AAA_Log //= require globals //= require AAB_message_factory diff --git a/web/app/assets/javascripts/createSession.js.erb b/web/app/assets/javascripts/createSession.js.erb index 494f8f661..b572c2ca7 100644 --- a/web/app/assets/javascripts/createSession.js.erb +++ b/web/app/assets/javascripts/createSession.js.erb @@ -21,6 +21,8 @@ function afterShow(data) { inviteMusiciansUtil.loadFriends(); + + context.JK.guardAgainstBrowser(app); } function resetForm() { @@ -117,11 +119,15 @@ function submitForm(evt) { evt.preventDefault(); + if(!gon.isNativeClient) { + return false; + } + // If user hasn't completed FTUE - do so now. if (!(context.JK.hasOneConfiguredDevice())) { app.afterFtue = function() { submitForm(evt); }; app.layout.startNewFtue(); - return; + return false; } // if for some reason there are 0 tracks, show FTUE @@ -131,7 +137,7 @@ // If user hasn't completed FTUE - do so now. app.afterFtue = function() { submitForm(evt); }; app.layout.startNewFtue(); - return; + return false; } var isValid = validateForm(); diff --git a/web/app/assets/javascripts/feed_item_recording.js b/web/app/assets/javascripts/feed_item_recording.js index 0d8793446..834aedae5 100644 --- a/web/app/assets/javascripts/feed_item_recording.js +++ b/web/app/assets/javascripts/feed_item_recording.js @@ -7,6 +7,7 @@ var claimedRecordingId = $parentElement.attr('data-claimed-recording-id'); var recordingId = $parentElement.attr('id'); + var mode = $parentElement.attr('data-mode'); var $feedItem = $parentElement; var $name = $('.name', $feedItem); diff --git a/web/app/assets/javascripts/findSession.js b/web/app/assets/javascripts/findSession.js index 31421dfcd..a684081cb 100644 --- a/web/app/assets/javascripts/findSession.js +++ b/web/app/assets/javascripts/findSession.js @@ -346,6 +346,7 @@ buildQuery(); refreshDisplay(); loadSessions(); + context.JK.guardAgainstBrowser(app); } function clearResults() { diff --git a/web/app/assets/javascripts/jam_rest.js b/web/app/assets/javascripts/jam_rest.js index 2b19b5d44..fbdf74a2a 100644 --- a/web/app/assets/javascripts/jam_rest.js +++ b/web/app/assets/javascripts/jam_rest.js @@ -934,6 +934,7 @@ } function getNotifications(options) { + if(!options) options = {}; var id = getId(options); return $.ajax({ type: "GET", diff --git a/web/app/assets/javascripts/jamkazam.js b/web/app/assets/javascripts/jamkazam.js index ea0a39710..f00e9b02d 100644 --- a/web/app/assets/javascripts/jamkazam.js +++ b/web/app/assets/javascripts/jamkazam.js @@ -28,6 +28,8 @@ var lastHeartbeatFound = false; var heartbeatAckCheckInterval = null; var userDeferred = null; + var notificationLastSeenAt = undefined; + var notificationLastSeen = undefined; var opts = { inClient: true, // specify false if you want the app object but none of the client-oriented features @@ -91,8 +93,9 @@ function _heartbeat() { if (app.heartbeatActive) { - - var message = context.JK.MessageFactory.heartbeat(); + var message = context.JK.MessageFactory.heartbeat(notificationLastSeen, notificationLastSeenAt); + notificationLastSeenAt = undefined; + notificationLastSeen = undefined; context.JK.JamServer.send(message); lastHeartbeatFound = false; } @@ -384,6 +387,28 @@ return userDeferred; } + this.updateNotificationSeen = function(notificationId, notificationCreatedAt) { + var time = new Date(notificationCreatedAt); + + if(!notificationCreatedAt) { + throw 'invalid value passed to updateNotificationSeen' + } + + if(!notificationLastSeenAt) { + notificationLastSeenAt = notificationCreatedAt; + notificationLastSeen = notificationId; + logger.debug("updated notificationLastSeenAt with: " + notificationCreatedAt); + } + else if(time.getTime() > new Date(notificationLastSeenAt).getTime()) { + notificationLastSeenAt = notificationCreatedAt; + notificationLastSeen = notificationId; + logger.debug("updated notificationLastSeenAt with: " + notificationCreatedAt); + } + else { + logger.debug("ignored notificationLastSeenAt for: " + notificationCreatedAt); + } + } + this.unloadFunction = function () { logger.debug("window.unload function called."); diff --git a/web/app/assets/javascripts/jquery.custom-protocol.js b/web/app/assets/javascripts/jquery.custom-protocol.js new file mode 100644 index 000000000..3e319fa48 --- /dev/null +++ b/web/app/assets/javascripts/jquery.custom-protocol.js @@ -0,0 +1,139 @@ +/*! + * jQuery Custom Protocol Launcher v0.0.1 + * https://github.com/sethcall/jquery-custom-protocol + * + * Taken and modified from: + * https://gist.github.com/rajeshsegu/3716941 + * http://stackoverflow.com/a/22055638/834644 + * + * Depends on: + * https://github.com/gabceb/jquery-browser-plugin + * + * Modifications Copyright 2014 Seth Call + * https://github.com/sethcall + * + * Released under the MIT license + */ + +(function (jQuery, window, undefined) { + "use strict"; + + function launchCustomProtocol(elem, url, options) { + var myWindow, success = false; + + if (!url) { + throw "attribute 'href' must be specified on the element, or specified in options" + } + if (!options.callback) { + throw "Specify 'callback' as an option to $.customProtocol"; + } + + var settings = $.extend({}, options); + + var callback = settings.callback; + + if ($.browser.msie) { + return ieTest(elem, url, callback); + } + else if ($.browser.mozilla) { + return iframeTest(elem, url, callback); + } + else if ($.browser.chrome) { + return blurTest(elem, url, callback); + } + } + + function blurTest(elem, url, callback) { + var timeout = null; + // If focus is taken, assume a launch dialog was shown + elem.css({"outline": 0}); + elem.attr("tabindex", "1"); + elem.focus(); + + function cleanup() { + elem.off('blur'); + elem.removeAttr("tabindex"); + if(timeout) { + clearTimeout(timeout) + timeout = null; + } + } + elem.blur(function () { + cleanup(); + callback(true); + }); + + location.replace(url); + + timeout = setTimeout(function () { + timeout = null; + cleanup(); + callback(false); + }, 1000); + + return false; + } + + function iframeTest(elem, url, callback) { + var iframe, success = false; + + try { + iframe = $("