diff --git a/.codeclimate.yml b/.codeclimate.yml index 719b2807..a187069d 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -20,6 +20,8 @@ engines: enabled: true rubocop: enabled: true + exclude_fingerprints: + - 74f18007b920e8d81148d2f6a2756534 ratings: paths: - 'Gemfile.lock' diff --git a/Gemfile b/Gemfile index 9fd59b62..0d8e8d7a 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,7 @@ gem 'exception_notification' gem 'httparty' gem 'json' gem 'kaminari' +gem 'mailboxer' gem 'paperclip' gem 'pg' gem 'pundit' diff --git a/Gemfile.lock b/Gemfile.lock index 92215068..d104cb51 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -65,6 +65,12 @@ GEM brakeman (3.4.0) builder (3.2.2) byebug (9.0.5) + carrierwave (0.11.2) + activemodel (>= 3.2.0) + activesupport (>= 3.2.0) + json (>= 1.7) + mime-types (>= 1.16) + mimemagic (>= 0.3.0) climate_control (0.0.3) activesupport (>= 3.0) cocaine (0.5.8) @@ -125,6 +131,9 @@ GEM nokogiri (>= 1.5.9) mail (2.6.4) mime-types (>= 1.16, < 4) + mailboxer (0.14.0) + carrierwave (>= 0.5.8) + rails (>= 4.2.0) method_source (0.8.2) mime-types (3.1) mime-types-data (~> 3.2015) @@ -284,6 +293,7 @@ DEPENDENCIES json json-schema kaminari + mailboxer paperclip pg pry-byebug diff --git a/app/assets/images/.DS_Store b/app/assets/images/.DS_Store deleted file mode 100644 index 2bbe5f6a..00000000 Binary files a/app/assets/images/.DS_Store and /dev/null differ diff --git a/app/assets/images/topright_sprite.png b/app/assets/images/topright_sprite.png index 163dd6f7..4c969887 100644 Binary files a/app/assets/images/topright_sprite.png and b/app/assets/images/topright_sprite.png differ diff --git a/app/assets/images/user_sprite.png b/app/assets/images/user_sprite.png old mode 100755 new mode 100644 index 72bcafac..045d48fd Binary files a/app/assets/images/user_sprite.png and b/app/assets/images/user_sprite.png differ diff --git a/app/assets/stylesheets/application.scss.erb b/app/assets/stylesheets/application.scss.erb index a0a915b7..988b8241 100644 --- a/app/assets/stylesheets/application.scss.erb +++ b/app/assets/stylesheets/application.scss.erb @@ -826,7 +826,7 @@ label { position:absolute; pointer-events:none; background-repeat:no-repeat; - background-image: url(<%= asset_data_uri('user_sprite.png') %>); + background-image: url(<%= asset_path('user_sprite.png') %>); } .accountSettings .accountIcon { background-position: 0 0; @@ -3076,3 +3076,7 @@ script.data-gratipay-username { display: inline; float: left; } + +.inline { + display: inline-block; +} diff --git a/app/assets/stylesheets/apps.css.erb b/app/assets/stylesheets/apps.css.erb index e6d75dd7..5771e366 100644 --- a/app/assets/stylesheets/apps.css.erb +++ b/app/assets/stylesheets/apps.css.erb @@ -1,17 +1,14 @@ .centerContent { position: relative; - margin: 92px auto 0 auto; - padding: 20px 0 60px 20px; - width: 760px; + margin: 0 auto; + width: auto; + max-width: 800px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.24); background: #fff; - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; - border: 1px solid #dcdcdc; - margin-bottom: 10px; + box-sizing: border-box; padding: 15px; + font-family: 'din-regular', sans-serif; } .centerContent .page-header { @@ -129,3 +126,9 @@ box-sizing: border-box; border-radius: 2px; } + +.centerContent.withPadding { + margin-top: 1em; + margin-bottom: 1em; +} + diff --git a/app/assets/stylesheets/clean.css.erb b/app/assets/stylesheets/clean.css.erb index e4da394b..3970f877 100644 --- a/app/assets/stylesheets/clean.css.erb +++ b/app/assets/stylesheets/clean.css.erb @@ -28,6 +28,8 @@ position: absolute; width: 100%; height: 100%; + box-sizing: border-box; + padding-top: 92px; } /*.animations { @@ -210,7 +212,13 @@ } .addMap { background-position: -96px 0; - margin-right:10px; +} +.notificationsIcon { + background-position: -128px 0; + margin-right: 10px; // make it look more natural next to the account menu icon +} +.notificationsIcon:hover { + background-position: -128px -32px; } .importDialog:hover { background-position: 0 -32px; @@ -223,7 +231,6 @@ } .addMap:hover { background-position: -96px -32px; - margin-right:10px; } @@ -471,7 +478,7 @@ background-position: -32px 0; } -.zoomExtents:hover .tooltips, .zoomIn:hover .tooltips, .zoomOut:hover .tooltips, .takeScreenshot:hover .tooltips, .sidebarFilterIcon:hover .tooltipsUnder, .sidebarForkIcon:hover .tooltipsUnder, .addMap:hover .tooltipsUnder, .authenticated .sidebarAccountIcon:hover .tooltipsUnder, +.zoomExtents:hover .tooltips, .zoomIn:hover .tooltips, .zoomOut:hover .tooltips, .takeScreenshot:hover .tooltips, .sidebarFilterIcon:hover .tooltipsUnder, .sidebarForkIcon:hover .tooltipsUnder, .notificationsIcon:hover .tooltipsUnder, .addMap:hover .tooltipsUnder, .authenticated .sidebarAccountIcon:hover .tooltipsUnder, .mapInfoIcon:hover .tooltipsAbove, .openCheatsheet:hover .tooltipsAbove, .chat-button:hover .tooltips, .importDialog:hover .tooltipsUnder, .starMap:hover .tooltipsAbove, .openMetacodeSwitcher:hover .tooltipsAbove, .pinCarousel:not(.isPinned):hover .tooltipsAbove.helpPin, .pinCarousel.isPinned:hover .tooltipsAbove.helpUnpin { display: block; } @@ -535,6 +542,9 @@ .sidebarFilterIcon .tooltipsUnder { margin-left: -4px; } +.notificationsIcon .tooltipsUnder { + left: -20px; +} .sidebarForkIcon .tooltipsUnder { margin-left: -34px; @@ -612,7 +622,12 @@ border-bottom: 5px solid transparent; } -.importDialog div:after, .sidebarFilterIcon div:after, .sidebarForkIcon div:after, .addMap div:after, .sidebarAccountIcon .tooltipsUnder:after { +.addMap div:after, +.importDialog div:after, +.sidebarForkIcon div:after, +.sidebarFilterIcon div:after, +.notificationsIcon div:after, +.sidebarAccountIcon .tooltipsUnder:after { content: ''; position: absolute; right: 40%; @@ -623,9 +638,15 @@ border-left: 5px solid transparent; border-right: 5px solid transparent; } +.notificationsIcon .unread-notifications-dot:after { + content: none; +} .sidebarFilterIcon div:after { right: 37% !important; } +.notificationsIcon div:after { + right: 46% !important; +} .mapInfoIcon div:after, .openCheatsheet div:after, .starMap div:after, .openMetacodeSwitcher div:after, .pinCarousel div:after { content: ''; @@ -758,7 +779,7 @@ } .exploreMapsCenter .authedApps .exploreMapsIcon { - background-image: url(<%= asset_data_uri('user_sprite.png') %>); + background-image: url(<%= asset_path('user_sprite.png') %>); background-position: 0 -32px; } .exploreMapsCenter .myMaps .exploreMapsIcon { @@ -781,6 +802,10 @@ background-image: url(<%= asset_path 'exploremaps_sprite.png' %>); background-position: -96px 0; } +.exploreMapsCenter .notificationsLink .exploreMapsIcon { + background-image: url(<%= asset_path 'topright_sprite.png' %>); + background-position: -128px 0; +} .authedApps:hover .exploreMapsIcon, .authedApps.active .exploreMapsIcon { background-position-x: -32px; } @@ -799,6 +824,9 @@ .sharedMaps:hover .exploreMapsIcon, .sharedMaps.active .exploreMapsIcon { background-position: -128px -32px; } +.notificationsLink:hover .exploreMapsIcon, .notificationsLink.active .exploreMapsIcon { + background-position-y: -32px; +} .mapsWrapper { /*overflow-y: auto; */ diff --git a/app/assets/stylesheets/mobile.scss.erb b/app/assets/stylesheets/mobile.scss.erb index 01fa5f61..fc34168d 100644 --- a/app/assets/stylesheets/mobile.scss.erb +++ b/app/assets/stylesheets/mobile.scss.erb @@ -2,12 +2,19 @@ display: none; } -@media only screen and (max-width : 720px) and (min-width : 504px) { +@media only screen and (max-width : 752px) and (min-width : 504px) { .sidebarSearch .tt-hint, .sidebarSearch .sidebarSearchField { width: 160px !important; } } +/* when this switches to two lines */ +@media only screen and (max-width : 728px) { + .controller-notifications .notificationsPage .notification .notification-read-unread a { + margin-top: -20px !important; + } +} + @media only screen and (max-width : 390px) { .map .mapCard .mobileMetadata { width: 190px; @@ -18,6 +25,14 @@ width: 390px; } } +/* 800 is the max-width for centerContent */ +@media only screen and (max-width : 800px) { + .centerContent.withPadding { + margin-top: 0; + margin-bottom: 0; + } +} + /* Smartphones (portrait and landscape) ----------- the minimum space that two map cards can fit side by side */ @media only screen and (max-width : 504px) { @@ -25,6 +40,17 @@ display: none !important; } + .notificationsPage .page-header { + display: none; + } + + .controller-notifications .notificationsPage .notification .notification-read-unread { + display: block !important; + } + .controller-notifications .notificationsPage .notification .notification-date { + display: none; + } + #mobile_header { display: block; } @@ -57,7 +83,7 @@ } #yield { - height: 100%; + padding-top: 50px; } .new_session, .new_user, .edit_user, .login, .forgotPassword { @@ -66,7 +92,7 @@ left: auto; width: 78%; padding: 16px 10%; - margin: 50px auto 0 auto; + margin: 0 auto; } .centerGreyForm input[type="text"], .centerGreyForm input[type="email"], .centerGreyForm input[type="password"] { @@ -213,8 +239,17 @@ line-height: 50px; } +#mobile_header #menu_icon .unread-notifications-dot { + top: 5px; + left: 29px; + width: 12px; + height: 12px; + border: 3px solid #eee; + border-radius: 9px; +} + #mobile_menu { - display: none; + display: none; background: #EEE; position: fixed; top: 50px; @@ -222,11 +257,21 @@ padding: 10px; width: 200px; box-shadow: 3px 3px 3px rgba(0,0,0,0.23), 3px 3px 3px rgba(0,0,0,0.16); -} -#mobile_menu li { - padding: 10px; - list-style: none; + li { + padding: 10px; + list-style: none; + + &.notifications { + position: relative; + + .unread-notifications-dot { + top: 50%; + left: 0px; + margin-top: -4px; + } + } + } } /* diff --git a/app/assets/stylesheets/notifications.scss.erb b/app/assets/stylesheets/notifications.scss.erb new file mode 100644 index 00000000..16f96407 --- /dev/null +++ b/app/assets/stylesheets/notifications.scss.erb @@ -0,0 +1,138 @@ +$unread_notifications_dot_size: 8px; +.unread-notifications-dot { + width: $unread_notifications_dot_size; + height: $unread_notifications_dot_size; + background-color: #e22; + border-radius: $unread_notifications_dot_size / 2; + position: absolute; + top: 0; + right: 0; +} + +.upperRightUI { + .notificationsIcon { + position: relative; + } +} + +.controller-notifications { + ul.notifications { + list-style: none; + } + + .notificationPage, + .notificationsPage { + font-family: 'din-regular', Sans-Serif; + + & a:hover { + text-decoration: none; + } + + & > .notification-title { + border-bottom: 1px solid #eee; + padding-bottom: 0.25em; + margin-bottom: 0.5em; + } + + .back { + margin-top: 1em; + } + } + + .notificationsPage { + header { + margin-bottom: 0; + } + + .emptyInbox { + padding-top: 15px; + } + + .notification { + padding: 10px; + position: relative; + + &:hover { + background: #F6F6F6; + + .notification-read-unread { + display:block; + } + + .notification-date { + display: none; + } + } + + & > a { + float: left; + width: 85%; + box-sizing: border-box; + padding-right: 10px; + } + + .notification-actor { + float: left; + + img { + width: 32px; + height: 32px; + border-radius: 16px; + } + } + + .notification-body { + margin-left: 50px; + + .in-bold { + font-family: 'din-medium', Sans-Serif; + } + + .action { + background: #4fb5c0; + color: #FFF; + padding: 2px 6px; + border-radius: 3px; + display: inline-block; + margin: 5px 0; + } + } + + .notification-date { + position: absolute; + top: 50%; + right: 10px; + color: #607d8b; + font-size: 13px; + line-height: 13px; + margin-top: -6px; + } + + .notification-read-unread { + display: none; + float: left; + width: 15%; + + a { + position: absolute; + top: 50%; + margin-top: -10px; + text-align: center; + } + } + + &.unread { + background: #EEE; + } + } + + } + + + + .notificationPage .notification-body { + p, div { + margin: 1em auto; + } + } +} diff --git a/app/controllers/access_controller.rb b/app/controllers/access_controller.rb index c48ac418..2441aa62 100644 --- a/app/controllers/access_controller.rb +++ b/app/controllers/access_controller.rb @@ -6,7 +6,6 @@ class AccessController < ApplicationController :deny_access, :deny_access_post, :request_access] after_action :verify_authorized - # GET maps/:id/request_access def request_access @map = nil @@ -20,13 +19,10 @@ class AccessController < ApplicationController # POST maps/:id/access_request def access_request request = AccessRequest.create(user: current_user, map: @map) - # what about push notification to map owner? - MapMailer.access_request_email(request, @map).deliver_later + NotificationService.access_request(request) respond_to do |format| - format.json do - head :ok - end + format.json { head :ok } end end @@ -36,22 +32,21 @@ class AccessController < ApplicationController @map.add_new_collaborators(user_ids).each do |user_id| # add_new_collaborators returns array of added users, - # who we then send an email to - MapMailer.invite_to_edit_email(@map, current_user, User.find(user_id)).deliver_later + # who we then send a notification to + user = User.find(user_id) + NotificationService.invite_to_edit(@map, current_user, user) end @map.remove_old_collaborators(user_ids) respond_to do |format| - format.json do - head :ok - end + format.json { head :ok } end end # GET maps/:id/approve_access/:request_id def approve_access request = AccessRequest.find(params[:request_id]) - request.approve() + request.approve # also marks mailboxer notification as read respond_to do |format| format.html { redirect_to map_path(@map), notice: 'Request was approved' } end @@ -60,7 +55,7 @@ class AccessController < ApplicationController # GET maps/:id/deny_access/:request_id def deny_access request = AccessRequest.find(params[:request_id]) - request.deny() + request.deny # also marks mailboxer notification as read respond_to do |format| format.html { redirect_to map_path(@map), notice: 'Request was turned down' } end @@ -69,7 +64,7 @@ class AccessController < ApplicationController # POST maps/:id/approve_access/:request_id def approve_access_post request = AccessRequest.find(params[:request_id]) - request.approve() + request.approve respond_to do |format| format.json do head :ok @@ -80,7 +75,7 @@ class AccessController < ApplicationController # POST maps/:id/deny_access/:request_id def deny_access_post request = AccessRequest.find(params[:request_id]) - request.deny() + request.deny respond_to do |format| format.json do head :ok @@ -94,5 +89,4 @@ class AccessController < ApplicationController @map = Map.find(params[:id]) authorize @map end - end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4285682e..4bb5be10 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -22,7 +22,7 @@ class ApplicationController < ActionController::Base helper_method :admin? def handle_unauthorized - if authenticated? and params[:controller] == 'maps' and params[:action] == 'show' + if authenticated? && (params[:controller] == 'maps') && (params[:action] == 'show') redirect_to request_access_map_path(params[:id]) elsif authenticated? redirect_to root_path, notice: "You don't have permission to see that page." @@ -41,13 +41,13 @@ class ApplicationController < ActionController::Base def require_no_user return true unless authenticated? redirect_to edit_user_path(user), notice: 'You must be logged out.' - return false + false end def require_user return true if authenticated? redirect_to sign_in_path, notice: 'You must be logged in.' - return false + false end def require_admin diff --git a/app/controllers/maps_controller.rb b/app/controllers/maps_controller.rb index d66456b8..6e1e0d77 100644 --- a/app/controllers/maps_controller.rb +++ b/app/controllers/maps_controller.rb @@ -8,6 +8,7 @@ class MapsController < ApplicationController def show respond_to do |format| format.html do + UserMap.where(map: @map, user: current_user).map(&:mark_invite_notifications_as_read) @allmappers = @map.contributors @allcollaborators = @map.editors @alltopics = policy_scope(@map.topics) diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb new file mode 100644 index 00000000..16049fc0 --- /dev/null +++ b/app/controllers/notifications_controller.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true +class NotificationsController < ApplicationController + before_action :set_receipts, only: [:index, :show, :mark_read, :mark_unread] + before_action :set_notification, only: [:show, :mark_read, :mark_unread] + before_action :set_receipt, only: [:show, :mark_read, :mark_unread] + + def index + @notifications = current_user.mailbox.notifications.page(params[:page]).per(25) + + respond_to do |format| + format.html + format.json do + render json: @notifications.map do |notification| + receipt = @receipts.find_by(notification_id: notification.id) + notification.as_json.merge(is_read: receipt.is_read) + end + end + end + end + + def show + @receipt.update(is_read: true) + respond_to do |format| + format.html + format.json do + render json: @notification.as_json.merge( + is_read: @receipt.is_read + ) + end + end + end + + def mark_read + @receipt.update(is_read: true) + respond_to do |format| + format.js + format.json do + render json: @notification.as_json.merge( + is_read: @receipt.is_read + ) + end + end + end + + def mark_unread + @receipt.update(is_read: false) + respond_to do |format| + format.js + format.json do + render json: @notification.as_json.merge( + is_read: @receipt.is_read + ) + end + end + end + + def unsubscribe + unsubscribe_redirect_if_logged_out! + check_if_already_unsubscribed! + return if performed? # if one of these checks already redirected, we're done + + if current_user.update(emails_allowed: false) + redirect_to edit_user_path(current_user), + notice: 'You will no longer receive emails from Metamaps.' + else + flash[:alert] = 'Sorry, something went wrong. You have not been unsubscribed from emails.' + redirect_to edit_user_path(current_user) + end + end + + private + + def unsubscribe_redirect_if_logged_out! + return if current_user.present? + + flash[:notice] = 'Continue to unsubscribe from emails by logging in.' + redirect_to "#{sign_in_path}?redirect_to=#{unsubscribe_notifications_path}" + end + + def check_if_already_unsubscribed! + return if current_user.emails_allowed + + redirect_to edit_user_path(current_user), notice: 'You were already unsubscribed from emails.' + end + + def set_receipts + @receipts = current_user.mailboxer_notification_receipts + end + + def set_notification + @notification = current_user.mailbox.notifications.find_by(id: params[:id]) + end + + def set_receipt + @receipt = @receipts.find_by(notification_id: params[:id]) + end +end diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index ea56059b..b54cc4f5 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -14,14 +14,14 @@ class TopicsController < ApplicationController @topics = policy_scope(Topic).where('LOWER("name") like ?', term.downcase + '%').order('"name"') @mapTopics = @topics.select { |t| t&.metacode&.name == 'Metamap' } # prioritize topics which point to maps, over maps - @exclude = @mapTopics.length > 0 ? @mapTopics.map(&:name) : [''] + @exclude = @mapTopics.length.positive? ? @mapTopics.map(&:name) : [''] @maps = policy_scope(Map).where('LOWER("name") like ? AND name NOT IN (?)', term.downcase + '%', @exclude).order('"name"') else @topics = [] @maps = [] end - @all= @topics.to_a.concat(@maps.to_a).sort { |a, b| a.name <=> b.name } - + @all = @topics.to_a.concat(@maps.to_a).sort_by(&:name) + render json: autocomplete_array_json(@all).to_json end diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 7c211f26..44bcb2de 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -21,13 +21,10 @@ class Users::RegistrationsController < Devise::RegistrationsController end end - private def store_location - if params[:redirect_to] - store_location_for(User, params[:redirect_to]) - end + store_location_for(User, params[:redirect_to]) if params[:redirect_to] end def configure_sign_up_params diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb index fed670ae..1c9c0a1e 100644 --- a/app/controllers/users/sessions_controller.rb +++ b/app/controllers/users/sessions_controller.rb @@ -1,14 +1,25 @@ -class Users::SessionsController < Devise::SessionsController - protected +# frozen_string_literal: true +module Users + class SessionsController < Devise::SessionsController + after_action :store_location, only: [:new] - def after_sign_in_path_for(resource) - stored = stored_location_for(User) - return stored if stored + protected - if request.referer&.match(sign_in_url) || request.referer&.match(sign_up_url) - super - else - request.referer || root_path + def after_sign_in_path_for(resource) + stored = stored_location_for(User) + return stored if stored + + if request.referer&.match(sign_in_url) || request.referer&.match(sign_up_url) + super + else + request.referer || root_path + end + end + + private + + def store_location + store_location_for(User, params[:redirect_to]) if params[:redirect_to] end end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index a9fff9de..1defb323 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -13,13 +13,12 @@ class UsersController < ApplicationController # GET /users/:id/edit def edit - @user = current_user - respond_with(@user) + @user = User.find(current_user.id) end # PUT /users/:id def update - @user = current_user + @user = User.find(current_user.id) if user_params[:password] == '' && user_params[:password_confirmation] == '' # not trying to change the password @@ -96,6 +95,8 @@ class UsersController < ApplicationController private def user_params - params.require(:user).permit(:name, :email, :image, :password, :password_confirmation) + params.require(:user).permit( + :name, :email, :image, :password, :password_confirmation, :emails_allowed + ) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3221aa34..96b5a2b2 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -37,4 +37,11 @@ module ApplicationHelper def invite_link "#{request.base_url}/join" + (current_user ? "?code=#{current_user.code}" : '') end + + def user_unread_notification_count + return 0 if current_user.nil? + @uunc ||= current_user.mailboxer_notification_receipts.reduce(0) do |total, receipt| + receipt.is_read ? total : total + 1 + end + end end diff --git a/app/helpers/topics_helper.rb b/app/helpers/topics_helper.rb index 58f53a6e..fa26095d 100644 --- a/app/helpers/topics_helper.rb +++ b/app/helpers/topics_helper.rb @@ -20,7 +20,7 @@ module TopicsHelper type: is_map ? metamapMetacode.name : t.metacode.name, typeImageURL: is_map ? metamapMetacode.icon : t.metacode.icon, mapCount: is_map ? 0 : t.maps.count, - synapseCount: is_map ? 0 : t.synapses.count, + synapseCount: is_map ? 0 : t.synapses.count } end end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 59a2175a..ebffb2df 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -2,4 +2,23 @@ class ApplicationMailer < ActionMailer::Base default from: 'team@metamaps.cc' layout 'mailer' + + def deliver + raise NotImplementedError('Please use Mailboxer to send your emails.') + end + + class << self + def mail_for_notification(notification) + if notification.notification_code == MAILBOXER_CODE_ACCESS_REQUEST + request = notification.notified_object + MapMailer.access_request_email(request) + elsif notification.notification_code == MAILBOXER_CODE_ACCESS_APPROVED + request = notification.notified_object + MapMailer.access_approved_email(request) + elsif notification.notification_code == MAILBOXER_CODE_INVITE_TO_EDIT + user_map = notification.notified_object + MapMailer.invite_to_edit_email(user_map.map, user_map.map.user, user_map.user) + end + end + end end diff --git a/app/mailers/map_mailer.rb b/app/mailers/map_mailer.rb index f6865ecd..bf0cec7b 100644 --- a/app/mailers/map_mailer.rb +++ b/app/mailers/map_mailer.rb @@ -2,17 +2,21 @@ class MapMailer < ApplicationMailer default from: 'team@metamaps.cc' - def access_request_email(request, map) + def access_request_email(request) @request = request - @map = map - subject = @map.name + ' - request to edit' - mail(to: @map.user.email, subject: subject) + @map = request.map + mail(to: @map.user.email, subject: request.requested_text) + end + + def access_approved_email(request) + @request = request + @map = request.map + mail(to: request.user, subject: request.approved_text) end def invite_to_edit_email(map, inviter, invitee) @inviter = inviter @map = map - subject = @map.name + ' - invitation to edit' - mail(to: invitee.email, subject: subject) + mail(to: invitee.email, subject: map.invited_text) end end diff --git a/app/models/access_request.rb b/app/models/access_request.rb index 185a04f0..fe68ce8f 100644 --- a/app/models/access_request.rb +++ b/app/models/access_request.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true class AccessRequest < ApplicationRecord belongs_to :user belongs_to :map @@ -5,14 +6,31 @@ class AccessRequest < ApplicationRecord def approve self.approved = true self.answered = true - self.save - UserMap.create(user: self.user, map: self.map) - MapMailer.invite_to_edit_email(self.map, self.map.user, self.user).deliver_later + save + + Mailboxer::Notification.where(notified_object: self).find_each do |notification| + Mailboxer::Receipt.where(notification: notification).update_all(is_read: true) + end + + user_map = UserMap.create(user: user, map: map) + NotificationService.access_approved(self) end def deny self.approved = false self.answered = true - self.save + save + + Mailboxer::Notification.where(notified_object: self).find_each do |notification| + Mailboxer::Receipt.where(notification: notification).update_all(is_read: true) + end + end + + def requested_text + map.name + ' - request to edit' + end + + def approved_text + map.name + ' - access approved' end end diff --git a/app/models/map.rb b/app/models/map.rb index 36b2d284..899992c6 100644 --- a/app/models/map.rb +++ b/app/models/map.rb @@ -18,11 +18,11 @@ class Map < ApplicationRecord # This method associates the attribute ":image" with a file attachment has_attached_file :screenshot, - styles: { - thumb: ['220x220#', :png] - #:full => ['940x630#', :png] - }, - default_url: 'https://s3.amazonaws.com/metamaps-assets/site/missing-map-square.png' + styles: { + thumb: ['220x220#', :png] + #:full => ['940x630#', :png] + }, + default_url: 'https://s3.amazonaws.com/metamaps-assets/site/missing-map-square.png' validates :name, presence: true validates :arranged, inclusion: { in: [true, false] } @@ -123,4 +123,8 @@ class Map < ApplicationRecord Topic.where(defer_to_map_id: id).update_all(permission: permission) Synapse.where(defer_to_map_id: id).update_all(permission: permission) end + + def invited_text + name + ' - invited to edit' + end end diff --git a/app/models/user.rb b/app/models/user.rb index 23ef6440..f6fcb60e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,6 +2,8 @@ require 'open-uri' class User < ApplicationRecord + acts_as_messageable # mailboxer notifications + has_many :topics has_many :synapses has_many :maps @@ -108,4 +110,19 @@ class User < ApplicationRecord def settings=(val) self[:settings] = val end + + # Mailboxer hooks and helper functions + + def mailboxer_email(_message) + return email if emails_allowed + # else return nil, which sends no email + end + + def mailboxer_notifications + mailbox.notifications + end + + def mailboxer_notification_receipts + mailbox.receipts.includes(:notification).where(mailbox_type: nil) + end end diff --git a/app/models/user_map.rb b/app/models/user_map.rb index dc268047..c1cdc58e 100644 --- a/app/models/user_map.rb +++ b/app/models/user_map.rb @@ -2,4 +2,10 @@ class UserMap < ApplicationRecord belongs_to :map belongs_to :user + + def mark_invite_notifications_as_read + Mailboxer::Notification.where(notified_object: self).find_each do |notification| + Mailboxer::Receipt.where(notification: notification).update_all(is_read: true) + end + end end diff --git a/app/models/webhooks/slack/base.rb b/app/models/webhooks/slack/base.rb index 2274e32c..ba4e9ea5 100644 --- a/app/models/webhooks/slack/base.rb +++ b/app/models/webhooks/slack/base.rb @@ -14,9 +14,7 @@ Webhooks::Slack::Base = Struct.new(:webhook, :event) do 'something' end - def channel - webhook.channel - end + delegate :channel, to: :webhook def attachments [{ diff --git a/app/policies/explore_policy.rb b/app/policies/explore_policy.rb index b4d52fe5..ce17d4f4 100644 --- a/app/policies/explore_policy.rb +++ b/app/policies/explore_policy.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true class ExplorePolicy < ApplicationPolicy def active? true diff --git a/app/policies/hack_policy.rb b/app/policies/hack_policy.rb index b6fbf6ce..bdc9eaab 100644 --- a/app/policies/hack_policy.rb +++ b/app/policies/hack_policy.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true class HackPolicy < ApplicationPolicy def load_url_title? true diff --git a/app/policies/map_policy.rb b/app/policies/map_policy.rb index f670f59e..937d564c 100644 --- a/app/policies/map_policy.rb +++ b/app/policies/map_policy.rb @@ -16,7 +16,7 @@ class MapPolicy < ApplicationPolicy end def show? - record.permission.in?(['commons', 'public']) || + record.permission.in?(%w(commons public)) || record.collaborators.include?(user) || record.user == user end diff --git a/app/policies/topic_policy.rb b/app/policies/topic_policy.rb index 64463b4a..bc80f657 100644 --- a/app/policies/topic_policy.rb +++ b/app/policies/topic_policy.rb @@ -22,7 +22,7 @@ class TopicPolicy < ApplicationPolicy if record.defer_to_map.present? map_policy.show? else - record.permission.in?(['commons', 'public']) || record.user == user + record.permission.in?(%w(commons public)) || record.user == user end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb new file mode 100644 index 00000000..aa919edb --- /dev/null +++ b/app/services/notification_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +class NotificationService + def self.renderer + renderer ||= ApplicationController.renderer.new( + http_host: ENV['MAILER_DEFAULT_URL'], + https: Rails.env.production? ? true : false + ) + end + + def self.access_request(request) + body = renderer.render(template: 'map_mailer/access_request_email', locals: { map: request.map, request: request }, layout: false) + request.map.user.notify(request.requested_text, body, request, false, MAILBOXER_CODE_ACCESS_REQUEST, true, request.user) + end + + def self.access_approved(request) + body = renderer.render(template: 'map_mailer/access_approved_email', locals: { map: request.map }, layout: false) + receipt = request.user.notify(request.approved_text, body, request, false, MAILBOXER_CODE_ACCESS_APPROVED, true, request.map.user) + end + + def self.invite_to_edit(map, inviter, invited) + user_map = UserMap.find_by(user: invited, map: map) + body = renderer.render(template: 'map_mailer/invite_to_edit_email', locals: { map: map, inviter: inviter }, layout: false) + invited.notify(map.invited_text, body, user_map, false, MAILBOXER_CODE_INVITE_TO_EDIT, true, inviter) + end + + def self.text_for_notification(notification) + if notification.notification_code == MAILBOXER_CODE_ACCESS_REQUEST + map = notification.notified_object.map + 'wants permission to map with you on ' + map.name + '  
Offer a response
' + elsif notification.notification_code == MAILBOXER_CODE_ACCESS_APPROVED + map = notification.notified_object.map + 'granted your request to edit map ' + map.name + '' + elsif notification.notification_code == MAILBOXER_CODE_INVITE_TO_EDIT + map = notification.notified_object.map + 'gave you edit access to map ' + map.name + '' + end + end +end diff --git a/app/views/layouts/_account.html.erb b/app/views/layouts/_account.html.erb index 748e5f1b..3d66f687 100644 --- a/app/views/layouts/_account.html.erb +++ b/app/views/layouts/_account.html.erb @@ -18,14 +18,14 @@ <%= link_to "Admin", metacodes_path %> <% end %> -
  • -
    - Share Invite -
  • <%= link_to "Apps", oauth_authorized_applications_path %>
  • +
  • +
    + Share Invite +
  • <%= link_to "Sign Out", "/logout", id: "Logout" %> diff --git a/app/views/layouts/_mobilemenu.html.erb b/app/views/layouts/_mobilemenu.html.erb index e012a808..5ef3a66d 100644 --- a/app/views/layouts/_mobilemenu.html.erb +++ b/app/views/layouts/_mobilemenu.html.erb @@ -2,7 +2,11 @@
    <%= yield(:mobile_title) %>
    - +
    - <%= form.label :name, "Name:", :class => "firstFieldText" %> - <%= form.text_field :name %> + <%= form.label :name, "Name:", class: 'firstFieldText' %> + <%= form.text_field :name %> +
    +
    + <%= form.label :email, "Email:", class: 'firstFieldText' %> + <%= form.email_field :email %> +
    +
    + <%= form.label :emails_allowed, class: 'firstFieldText' do %> + <%= form.check_box :emails_allowed, class: 'inline' %> + Send Metamaps notifications to my email. + <% end %>
    -
    <%= form.label :email, "Email:", :class => "firstFieldText" %> - <%= form.email_field :email %>
    Change Password
    -
    - <%= form.label :current_password, "Current Password:", :class => "firstFieldText" %> - <%= password_field_tag :current_password, params[:current_password] %> +
    + <%= form.label :current_password, "Current Password:", :class => "firstFieldText" %> + <%= password_field_tag :current_password, params[:current_password] %> +
    +
    + <%= form.label :password, "New Password:", :class => "firstFieldText" %> + <%= form.password_field :password, :autocomplete => :off%> +
    +
    + <%= form.label :password_confirmation, "Confirm New Password:", :class => "firstFieldText" %> + <%= form.password_field :password_confirmation, :autocomplete => :off%> +
    +
    Oops, don't change password
    -
    <%= form.label :password, "New Password:", :class => "firstFieldText" %> - <%= form.password_field :password, :autocomplete => :off%>
    -
    <%= form.label :password_confirmation, "Confirm New Password:", :class => "firstFieldText" %> - <%= form.password_field :password_confirmation, :autocomplete => :off%>
    -
    Oops, don't change password
    -
    <%= form.submit "Update", class: "update", onclick: "Metamaps.Account.showLoading()" %>
    diff --git a/config/application.rb b/config/application.rb index 0b98bfe8..ff5a621c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -8,14 +8,15 @@ Bundler.require(*Rails.groups) module Metamaps class Application < Rails::Application - config.active_job.queue_adapter = :delayed_job - if ENV['ACTIVE_JOB_FRAMEWORK'] == 'sucker_punch' - config.active_job.queue_adapter = :sucker_punch - end - # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. + # + config.active_job.queue_adapter = if ENV['ACTIVE_JOB_FRAMEWORK'] == 'sucker_punch' + :sucker_punch + else + :delayed_job + end # Custom directories with classes and modules you want to be autoloadable. config.autoload_paths << Rails.root.join('app', 'services') diff --git a/config/brakeman.ignore b/config/brakeman.ignore new file mode 100644 index 00000000..9e29ff0d --- /dev/null +++ b/config/brakeman.ignore @@ -0,0 +1,24 @@ +{ + "ignored_warnings": [ + { + "warning_type": "Cross Site Scripting", + "warning_code": 2, + "fingerprint": "88694dca0bcc2226859746f9ed40cc682d6e5eaec1e73f2be557770a854ede0b", + "message": "Unescaped model attribute", + "file": "app/views/notifications/show.html.erb", + "line": 7, + "link": "http://brakemanscanner.org/docs/warning_types/cross_site_scripting", + "code": "current_user.mailbox.notifications.find_by(:id => params[:id]).body", + "render_path": [{"type":"controller","class":"NotificationsController","method":"show","line":24,"file":"app/controllers/notifications_controller.rb"}], + "location": { + "type": "template", + "template": "notifications/show" + }, + "user_input": "current_user.mailbox.notifications", + "confidence": "Weak", + "note": "" + } + ], + "updated": "2016-11-29 13:01:34 -0500", + "brakeman_version": "3.4.0" +} diff --git a/config/environments/development.rb b/config/environments/development.rb index 38741a18..8fef2145 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -14,19 +14,11 @@ Rails.application.configure do config.consider_all_requests_local = true config.action_controller.perform_caching = false - config.action_mailer.delivery_method = :smtp - config.action_mailer.smtp_settings = { - address: ENV['SMTP_SERVER'], - port: ENV['SMTP_PORT'], - user_name: ENV['SMTP_USERNAME'], - password: ENV['SMTP_PASSWORD'], - domain: ENV['SMTP_DOMAIN'], - authentication: 'plain', - enable_starttls_auto: true, - openssl_verify_mode: 'none' + config.action_mailer.delivery_method = :file + config.action_mailer.file_settings = { + location: 'tmp/mails' } config.action_mailer.default_url_options = { host: 'localhost:3000' } - # Don't care if the mailer can't send config.action_mailer.raise_delivery_errors = true # Print deprecation notices to the Rails logger diff --git a/config/environments/production.rb b/config/environments/production.rb index d3f8794e..ab4769b6 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -1,16 +1,16 @@ + # frozen_string_literal: true Rails.application.configure do # Settings specified here will take precedence over those in config/application.rb - config.log_level = :warn - config.eager_load = true - - # 12 factor: log to stdout - logger = ActiveSupport::Logger.new(STDOUT) + # log to stdout + logger = Logger.new(STDOUT) logger.formatter = config.log_formatter + logger.level = :warn config.logger = ActiveSupport::TaggedLogging.new(logger) # Code is not reloaded between requests + config.eager_load = true config.cache_classes = true # Full error reports are disabled and caching is turned on diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 433b1c40..21b08bc2 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -9,20 +9,20 @@ Doorkeeper.configure do current_user else store_location_for(User, request.fullpath) - redirect_to(sign_in_url, notice: "Sign In to Connect") + redirect_to(sign_in_url, notice: 'Sign In to Connect') end end # If you want to restrict access to the web interface for adding oauth authorized applications, # you need to declare the block below. admin_authenticator do - if current_user && current_user.admin + if current_user&.admin current_user elsif current_user && !current_user.admin - redirect_to(root_url, notice: "Unauthorized") + redirect_to(root_url, notice: 'Unauthorized') else store_location_for(User, request.fullpath) - redirect_to(sign_in_url, notice: "Try signing in to do that") + redirect_to(sign_in_url, notice: 'Try signing in to do that') end end diff --git a/config/initializers/mailboxer.rb b/config/initializers/mailboxer.rb new file mode 100644 index 00000000..49824f50 --- /dev/null +++ b/config/initializers/mailboxer.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# notification codes to differentiate different types of notifications +# e.g. a notification might have { +# notified_object_type: 'Map', +# notified_object_id: 1, +# notification_code: MAILBOXER_CODE_ACCESS_REQUEST +# }, +# which would imply that this is an access request to Map.find(1) +MAILBOXER_CODE_ACCESS_REQUEST = 'ACCESS_REQUEST' +MAILBOXER_CODE_ACCESS_APPROVED = 'ACCESS_APPROVED' +MAILBOXER_CODE_INVITE_TO_EDIT = 'INVITE_TO_EDIT' + +Mailboxer.setup do |config| + # Configures if your application uses or not email sending for Notifications and Messages + config.uses_emails = true + + # Configures the default from for emails sent for Messages and Notifications + config.default_from = 'team@metamaps.cc' + + # Configures the methods needed by mailboxer + config.email_method = :mailboxer_email + config.name_method = :name + + # Configures if you use or not a search engine and which one you are using + # Supported engines: [:solr,:sphinx] + config.search_enabled = false + config.search_engine = :solr + + # Configures maximum length of the message subject and body + config.subject_max_length = 255 + config.body_max_length = 32_000 +end diff --git a/config/initializers/rack-attack.rb b/config/initializers/rack-attack.rb index 1cb90f0f..0fd76889 100644 --- a/config/initializers/rack-attack.rb +++ b/config/initializers/rack-attack.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true class Rack::Attack Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new @@ -11,10 +12,8 @@ class Rack::Attack # Throttle POST requests to /login by IP address # # Key: "rack::attack:#{Time.now.to_i/:period}:logins/ip:#{req.ip}" - throttle('logins/ip', :limit => 5, :period => 20.seconds) do |req| - if req.path == '/login' && req.post? - req.ip - end + throttle('logins/ip', limit: 5, period: 20.seconds) do |req| + req.ip if req.path == '/login' && req.post? end # Throttle POST requests to /login by email param @@ -25,17 +24,17 @@ class Rack::Attack # throttle logins for another user and force their login requests to be # denied, but that's not very common and shouldn't happen to you. (Knock # on wood!) - throttle("logins/email", :limit => 5, :period => 20.seconds) do |req| + throttle('logins/email', limit: 5, period: 20.seconds) do |req| if req.path == '/login' && req.post? # return the email if present, nil otherwise req.params['email'].presence end end - throttle('load_url_title/req/5mins/ip', :limit => 300, :period => 5.minutes) do |req| + throttle('load_url_title/req/5mins/ip', limit: 300, period: 5.minutes) do |req| req.ip if req.path == 'hacks/load_url_title' end - throttle('load_url_title/req/1s/ip', :limit => 5, :period => 1.second) do |req| + throttle('load_url_title/req/1s/ip', limit: 5, period: 1.second) do |req| # If the return value is truthy, the cache key for the return value # is incremented and compared with the limit. In this case: # "rack::attack:#{Time.now.to_i/1.second}:load_url_title/req/ip:#{req.ip}" @@ -46,16 +45,16 @@ class Rack::Attack end self.throttled_response = lambda do |env| - now = Time.now - match_data = env['rack.attack.match_data'] + now = Time.now + match_data = env['rack.attack.match_data'] period = match_data[:period] limit = match_data[:limit] - headers = { + headers = { 'X-RateLimit-Limit' => limit.to_s, - 'X-RateLimit-Remaining' => '0', - 'X-RateLimit-Reset' => (now + (period - now.to_i % period)).to_s - } + 'X-RateLimit-Remaining' => '0', + 'X-RateLimit-Reset' => (now + (period - now.to_i % period)).to_s + } [429, headers, ['']] end diff --git a/config/locales/en.yml b/config/locales/en.yml index 46d3db07..c4ada107 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,8 +1,4 @@ -# Sample localization file for English. Add more files in this directory for other locales. -# See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points. - en: - activerecord: - attributes: - user: - joinedwithcode: "Access code" + mailboxer: + notification_mailer: + subject: "%{subject}" diff --git a/config/routes.rb b/config/routes.rb index 8ba116a1..000784f6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,12 +20,25 @@ Metamaps::Application.routes.draw do post 'events/:event', action: :events get :contains - get :request_access, to: 'access#request_access' - get 'approve_access/:request_id', to: 'access#approve_access', as: :approve_access - get 'deny_access/:request_id', to: 'access#deny_access', as: :deny_access - post :access_request, to: 'access#access_request', default: { format: :json } - post 'approve_access/:request_id', to: 'access#approve_access_post', default: { format: :json } - post 'deny_access/:request_id', to: 'access#deny_access_post', default: { format: :json } + get :request_access, + to: 'access#request_access' + get 'approve_access/:request_id', + to: 'access#approve_access', + as: :approve_access + get 'deny_access/:request_id', + to: 'access#deny_access', + as: :deny_access + + post :access_request, + to: 'access#access_request', + default: { format: :json } + post 'approve_access/:request_id', + to: 'access#approve_access_post', + default: { format: :json } + post 'deny_access/:request_id', + to: 'access#deny_access_post', + default: { format: :json } + post :access, to: 'access#access', default: { format: :json } post :star, to: 'stars#create', default: { format: :json } @@ -36,6 +49,15 @@ Metamaps::Application.routes.draw do resources :mappings, except: [:index, :new, :edit] resources :messages, only: [:show, :create, :update, :destroy] + resources :notifications, only: [:index, :show] do + collection do + get :unsubscribe + end + member do + put :mark_read + put :mark_unread + end + end resources :metacode_sets, except: [:show] @@ -109,3 +131,4 @@ Metamaps::Application.routes.draw do get 'load_url_title' end end +# rubocop:enable Rubocop/Metrics/BlockLength diff --git a/db/migrate/20161101031231_create_mailboxer.mailboxer_engine.rb b/db/migrate/20161101031231_create_mailboxer.mailboxer_engine.rb new file mode 100644 index 00000000..99ed59b1 --- /dev/null +++ b/db/migrate/20161101031231_create_mailboxer.mailboxer_engine.rb @@ -0,0 +1,65 @@ +# This migration comes from mailboxer_engine (originally 20110511145103) +class CreateMailboxer < ActiveRecord::Migration + def self.up + #Tables + #Conversations + create_table :mailboxer_conversations do |t| + t.column :subject, :string, :default => "" + t.column :created_at, :datetime, :null => false + t.column :updated_at, :datetime, :null => false + end + #Receipts + create_table :mailboxer_receipts do |t| + t.references :receiver, :polymorphic => true + t.column :notification_id, :integer, :null => false + t.column :is_read, :boolean, :default => false + t.column :trashed, :boolean, :default => false + t.column :deleted, :boolean, :default => false + t.column :mailbox_type, :string, :limit => 25 + t.column :created_at, :datetime, :null => false + t.column :updated_at, :datetime, :null => false + end + #Notifications and Messages + create_table :mailboxer_notifications do |t| + t.column :type, :string + t.column :body, :text + t.column :subject, :string, :default => "" + t.references :sender, :polymorphic => true + t.column :conversation_id, :integer + t.column :draft, :boolean, :default => false + t.string :notification_code, :default => nil + t.references :notified_object, :polymorphic => true + t.column :attachment, :string + t.column :updated_at, :datetime, :null => false + t.column :created_at, :datetime, :null => false + t.boolean :global, default: false + t.datetime :expires + end + + #Indexes + #Conversations + #Receipts + add_index "mailboxer_receipts","notification_id" + + #Messages + add_index "mailboxer_notifications","conversation_id" + + #Foreign keys + #Conversations + #Receipts + add_foreign_key "mailboxer_receipts", "mailboxer_notifications", :name => "receipts_on_notification_id", :column => "notification_id" + #Messages + add_foreign_key "mailboxer_notifications", "mailboxer_conversations", :name => "notifications_on_conversation_id", :column => "conversation_id" + end + + def self.down + #Tables + remove_foreign_key "mailboxer_receipts", :name => "receipts_on_notification_id" + remove_foreign_key "mailboxer_notifications", :name => "notifications_on_conversation_id" + + #Indexes + drop_table :mailboxer_receipts + drop_table :mailboxer_conversations + drop_table :mailboxer_notifications + end +end diff --git a/db/migrate/20161101031232_add_conversation_optout.mailboxer_engine.rb b/db/migrate/20161101031232_add_conversation_optout.mailboxer_engine.rb new file mode 100644 index 00000000..c4f4555a --- /dev/null +++ b/db/migrate/20161101031232_add_conversation_optout.mailboxer_engine.rb @@ -0,0 +1,15 @@ +# This migration comes from mailboxer_engine (originally 20131206080416) +class AddConversationOptout < ActiveRecord::Migration + def self.up + create_table :mailboxer_conversation_opt_outs do |t| + t.references :unsubscriber, :polymorphic => true + t.references :conversation + end + add_foreign_key "mailboxer_conversation_opt_outs", "mailboxer_conversations", :name => "mb_opt_outs_on_conversations_id", :column => "conversation_id" + end + + def self.down + remove_foreign_key "mailboxer_conversation_opt_outs", :name => "mb_opt_outs_on_conversations_id" + drop_table :mailboxer_conversation_opt_outs + end +end diff --git a/db/migrate/20161101031233_add_missing_indices.mailboxer_engine.rb b/db/migrate/20161101031233_add_missing_indices.mailboxer_engine.rb new file mode 100644 index 00000000..fde96718 --- /dev/null +++ b/db/migrate/20161101031233_add_missing_indices.mailboxer_engine.rb @@ -0,0 +1,20 @@ +# This migration comes from mailboxer_engine (originally 20131206080417) +class AddMissingIndices < ActiveRecord::Migration + def change + # We'll explicitly specify its name, as the auto-generated name is too long and exceeds 63 + # characters limitation. + add_index :mailboxer_conversation_opt_outs, [:unsubscriber_id, :unsubscriber_type], + name: 'index_mailboxer_conversation_opt_outs_on_unsubscriber_id_type' + add_index :mailboxer_conversation_opt_outs, :conversation_id + + add_index :mailboxer_notifications, :type + add_index :mailboxer_notifications, [:sender_id, :sender_type] + + # We'll explicitly specify its name, as the auto-generated name is too long and exceeds 63 + # characters limitation. + add_index :mailboxer_notifications, [:notified_object_id, :notified_object_type], + name: 'index_mailboxer_notifications_on_notified_object_id_and_type' + + add_index :mailboxer_receipts, [:receiver_id, :receiver_type] + end +end diff --git a/db/migrate/20161101031234_add_delivery_tracking_info_to_mailboxer_receipts.mailboxer_engine.rb b/db/migrate/20161101031234_add_delivery_tracking_info_to_mailboxer_receipts.mailboxer_engine.rb new file mode 100644 index 00000000..a820919e --- /dev/null +++ b/db/migrate/20161101031234_add_delivery_tracking_info_to_mailboxer_receipts.mailboxer_engine.rb @@ -0,0 +1,8 @@ +# This migration comes from mailboxer_engine (originally 20151103080417) +class AddDeliveryTrackingInfoToMailboxerReceipts < ActiveRecord::Migration + def change + add_column :mailboxer_receipts, :is_delivered, :boolean, default: false + add_column :mailboxer_receipts, :delivery_method, :string + add_column :mailboxer_receipts, :message_id, :string + end +end diff --git a/db/migrate/20161125175229_add_emails_allowed_to_users.rb b/db/migrate/20161125175229_add_emails_allowed_to_users.rb new file mode 100644 index 00000000..609e4309 --- /dev/null +++ b/db/migrate/20161125175229_add_emails_allowed_to_users.rb @@ -0,0 +1,5 @@ +class AddEmailsAllowedToUsers < ActiveRecord::Migration[5.0] + def change + add_column :users, :emails_allowed, :boolean, default: true + end +end diff --git a/db/schema.rb b/db/schema.rb index d16d4fb9..5839929c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20161105160340) do +ActiveRecord::Schema.define(version: 20161125175229) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -63,6 +63,59 @@ ActiveRecord::Schema.define(version: 20161105160340) do t.index ["metacode_set_id"], name: "index_in_metacode_sets_on_metacode_set_id", using: :btree end + create_table "mailboxer_conversation_opt_outs", force: :cascade do |t| + t.string "unsubscriber_type" + t.integer "unsubscriber_id" + t.integer "conversation_id" + t.index ["conversation_id"], name: "index_mailboxer_conversation_opt_outs_on_conversation_id", using: :btree + t.index ["unsubscriber_id", "unsubscriber_type"], name: "index_mailboxer_conversation_opt_outs_on_unsubscriber_id_type", using: :btree + end + + create_table "mailboxer_conversations", force: :cascade do |t| + t.string "subject", default: "" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "mailboxer_notifications", force: :cascade do |t| + t.string "type" + t.text "body" + t.string "subject", default: "" + t.string "sender_type" + t.integer "sender_id" + t.integer "conversation_id" + t.boolean "draft", default: false + t.string "notification_code" + t.string "notified_object_type" + t.integer "notified_object_id" + t.string "attachment" + t.datetime "updated_at", null: false + t.datetime "created_at", null: false + t.boolean "global", default: false + t.datetime "expires" + t.index ["conversation_id"], name: "index_mailboxer_notifications_on_conversation_id", using: :btree + t.index ["notified_object_id", "notified_object_type"], name: "index_mailboxer_notifications_on_notified_object_id_and_type", using: :btree + t.index ["sender_id", "sender_type"], name: "index_mailboxer_notifications_on_sender_id_and_sender_type", using: :btree + t.index ["type"], name: "index_mailboxer_notifications_on_type", using: :btree + end + + create_table "mailboxer_receipts", force: :cascade do |t| + t.string "receiver_type" + t.integer "receiver_id" + t.integer "notification_id", null: false + t.boolean "is_read", default: false + t.boolean "trashed", default: false + t.boolean "deleted", default: false + t.string "mailbox_type", limit: 25 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "is_delivered", default: false + t.string "delivery_method" + t.string "message_id" + t.index ["notification_id"], name: "index_mailboxer_receipts_on_notification_id", using: :btree + t.index ["receiver_id", "receiver_type"], name: "index_mailboxer_receipts_on_receiver_id_and_receiver_type", using: :btree + end + create_table "mappings", force: :cascade do |t| t.text "category" t.integer "xloc" @@ -243,8 +296,8 @@ ActiveRecord::Schema.define(version: 20161105160340) do t.string "password_salt", limit: 255 t.string "persistence_token", limit: 255 t.string "perishable_token", limit: 255 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "code", limit: 8 t.string "joinedwithcode", limit: 8 t.text "settings" @@ -264,6 +317,7 @@ ActiveRecord::Schema.define(version: 20161105160340) do t.integer "image_file_size" t.datetime "image_updated_at" t.integer "generation" + t.boolean "emails_allowed", default: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree end @@ -279,5 +333,8 @@ ActiveRecord::Schema.define(version: 20161105160340) do add_foreign_key "access_requests", "maps" add_foreign_key "access_requests", "users" + add_foreign_key "mailboxer_conversation_opt_outs", "mailboxer_conversations", column: "conversation_id", name: "mb_opt_outs_on_conversations_id" + add_foreign_key "mailboxer_notifications", "mailboxer_conversations", column: "conversation_id", name: "notifications_on_conversation_id" + add_foreign_key "mailboxer_receipts", "mailboxer_notifications", column: "notification_id", name: "receipts_on_notification_id" add_foreign_key "tokens", "users" end diff --git a/doc/metamaps-qa-steps.md b/doc/metamaps-qa-steps.md index a3f136ca..7c5c8480 100644 --- a/doc/metamaps-qa-steps.md +++ b/doc/metamaps-qa-steps.md @@ -32,6 +32,13 @@ Run these tests to be reasonably sure that your code changes haven't broken anyt - Add a number of synapses to one of your maps. Reload to see if they are still there. - Rearrange one of your maps and save the layout. Reload to see if the layout is preserved. +### Unsubscribing from Notifications + + - Log out + - Visit /notifications/unsubscribe. It should redirect you to the login page. + - Log in. + - It should redirect you to the user edit page, and you should be unsubscribed. + ### Misc - Login as admin. Change metacode sets. diff --git a/frontend/src/Metamaps/GlobalUI/NotificationIcon.js b/frontend/src/Metamaps/GlobalUI/NotificationIcon.js new file mode 100644 index 00000000..1e9ff3bd --- /dev/null +++ b/frontend/src/Metamaps/GlobalUI/NotificationIcon.js @@ -0,0 +1,30 @@ +/* global $ */ + +import React from 'react' +import ReactDOM from 'react-dom' + +import Active from '../Active' +import NotificationIconComponent from '../../components/NotificationIcon' + +const NotificationIcon = { + unreadNotificationsCount: null, + + init: function(serverData) { + const self = NotificationIcon + self.unreadNotificationsCount = serverData.unreadNotificationsCount + self.render() + }, + render: function(newUnreadCount = null) { + if (newUnreadCount !== null) { + NotificationIcon.unreadNotificationsCount = newUnreadCount + } + + if (Active.Mapper !== null) { + ReactDOM.render(React.createElement(NotificationIconComponent, { + unreadNotificationsCount: NotificationIcon.unreadNotificationsCount + }), $('#notification_icon').get(0)) + } + } +} + +export default NotificationIcon diff --git a/frontend/src/Metamaps/GlobalUI/index.js b/frontend/src/Metamaps/GlobalUI/index.js index 95e484f8..932e3319 100644 --- a/frontend/src/Metamaps/GlobalUI/index.js +++ b/frontend/src/Metamaps/GlobalUI/index.js @@ -8,6 +8,7 @@ import Search from './Search' import CreateMap from './CreateMap' import Account from './Account' import ImportDialog from './ImportDialog' +import NotificationIcon from './NotificationIcon' const GlobalUI = { notifyTimeout: null, @@ -19,6 +20,7 @@ const GlobalUI = { self.CreateMap.init(serverData) self.Account.init(serverData) self.ImportDialog.init(serverData, self.openLightbox, self.closeLightbox) + self.NotificationIcon.init(serverData) if ($('#toast').html().trim()) self.notifyUser($('#toast').html()) @@ -127,5 +129,5 @@ const GlobalUI = { } } -export { Search, CreateMap, Account, ImportDialog } +export { Search, CreateMap, Account, ImportDialog, NotificationIcon } export default GlobalUI diff --git a/frontend/src/Metamaps/Map/InfoBox.js b/frontend/src/Metamaps/Map/InfoBox.js index 1b06daf5..5fced292 100644 --- a/frontend/src/Metamaps/Map/InfoBox.js +++ b/frontend/src/Metamaps/Map/InfoBox.js @@ -264,7 +264,7 @@ const InfoBox = { var mapperIds = DataModel.Collaborators.models.map(function(mapper) { return mapper.id }) $.post('/maps/' + Active.Map.id + '/access', { access: mapperIds }) var name = DataModel.Collaborators.get(newCollaboratorId).get('name') - GlobalUI.notifyUser(name + ' will be notified by email') + GlobalUI.notifyUser(name + ' will be notified') self.updateNumbers() } diff --git a/frontend/src/Metamaps/index.js b/frontend/src/Metamaps/index.js index dfad4d95..be218aff 100644 --- a/frontend/src/Metamaps/index.js +++ b/frontend/src/Metamaps/index.js @@ -8,7 +8,8 @@ import Create from './Create' import Debug from './Debug' import Filter from './Filter' import GlobalUI, { - Search, CreateMap, ImportDialog, Account as GlobalUIAccount + Search, CreateMap, ImportDialog, Account as GlobalUIAccount, + NotificationIcon } from './GlobalUI' import Import from './Import' import JIT from './JIT' @@ -47,6 +48,7 @@ Metamaps.GlobalUI.Search = Search Metamaps.GlobalUI.CreateMap = CreateMap Metamaps.GlobalUI.Account = GlobalUIAccount Metamaps.GlobalUI.ImportDialog = ImportDialog +Metamaps.GlobalUI.NotificationIcon = NotificationIcon Metamaps.Import = Import Metamaps.JIT = JIT Metamaps.Listeners = Listeners diff --git a/frontend/src/components/Maps/Header.js b/frontend/src/components/Maps/Header.js index c0a7e1cd..f360e7a5 100644 --- a/frontend/src/components/Maps/Header.js +++ b/frontend/src/components/Maps/Header.js @@ -36,6 +36,12 @@ class Header extends Component {
    + - { + let linkClasses = 'notificationsIcon upperRightEl upperRightIcon ' + + if (this.props.unreadNotificationsCount > 0) { + linkClasses += 'unread' + } else { + linkClasses += 'read' + } + + return ( + +
    + Notifications +
    + {this.props.unreadNotificationsCount === 0 ? null : ( +
    + )} +
    + + ) + } +} + +NotificationIcon.propTypes = { + unreadNotificationsCount: PropTypes.number +} + +export default NotificationIcon diff --git a/spec/api/v2/mappings_api_spec.rb b/spec/api/v2/mappings_api_spec.rb index 6f225c6a..f8854e91 100644 --- a/spec/api/v2/mappings_api_spec.rb +++ b/spec/api/v2/mappings_api_spec.rb @@ -18,7 +18,6 @@ RSpec.describe 'mappings API', type: :request do it 'GET /api/v2/mappings/:id' do get "/api/v2/mappings/#{mapping.id}", params: { access_token: token } - expect(response).to have_http_status(:success) expect(response).to match_json_schema(:mapping) expect(JSON.parse(response.body)['data']['id']).to eq mapping.id diff --git a/spec/api/v2/maps_api_spec.rb b/spec/api/v2/maps_api_spec.rb index a7edeef2..fbf07903 100644 --- a/spec/api/v2/maps_api_spec.rb +++ b/spec/api/v2/maps_api_spec.rb @@ -1,4 +1,5 @@ -#t frozen_string_literal: true +# frozen_string_literal: true +# t frozen_string_literal: true require 'rails_helper' RSpec.describe 'maps API', type: :request do @@ -35,7 +36,7 @@ RSpec.describe 'maps API', type: :request do expect(response).to match_json_schema(:map) expect(JSON.parse(response.body)['data']['id']).to eq map.id end - + it 'POST /api/v2/maps' do post '/api/v2/maps', params: { map: map.attributes, access_token: token } diff --git a/spec/api/v2/topics_api_spec.rb b/spec/api/v2/topics_api_spec.rb index 31d93b87..ac2fb56a 100644 --- a/spec/api/v2/topics_api_spec.rb +++ b/spec/api/v2/topics_api_spec.rb @@ -18,7 +18,6 @@ RSpec.describe 'topics API', type: :request do it 'GET /api/v2/topics/:id' do get "/api/v2/topics/#{topic.id}" - expect(response).to have_http_status(:success) expect(response).to match_json_schema(:topic) expect(JSON.parse(response.body)['data']['id']).to eq topic.id diff --git a/spec/controllers/synapses_controller_spec.rb b/spec/controllers/synapses_controller_spec.rb index 511971ad..7abeb2ee 100644 --- a/spec/controllers/synapses_controller_spec.rb +++ b/spec/controllers/synapses_controller_spec.rb @@ -53,9 +53,9 @@ RSpec.describe SynapsesController, type: :controller do expect(response.status).to eq 422 end it 'does not create a synapse' do - expect { + expect do post :create, format: :json, params: { synapse: invalid_attributes } - }.to change { + end.to change { Synapse.count }.by 0 end diff --git a/spec/mailers/map_mailer_spec.rb b/spec/mailers/map_mailer_spec.rb index 5fed48f5..a920746b 100644 --- a/spec/mailers/map_mailer_spec.rb +++ b/spec/mailers/map_mailer_spec.rb @@ -1,10 +1,11 @@ +# frozen_string_literal: true require 'rails_helper' RSpec.describe MapMailer, type: :mailer do describe 'access_request_email' do - let(:request) { create(:access_request) } let(:map) { create(:map) } - let(:mail) { described_class.access_request_email(request, map) } + let(:request) { create(:access_request, map: map) } + let(:mail) { described_class.access_request_email(request) } it { expect(mail.from).to eq ['team@metamaps.cc'] } it { expect(mail.to).to eq [map.user.email] } diff --git a/spec/mailers/previews/map_mailer_preview.rb b/spec/mailers/previews/map_mailer_preview.rb index 17ea7671..61e33eb8 100644 --- a/spec/mailers/previews/map_mailer_preview.rb +++ b/spec/mailers/previews/map_mailer_preview.rb @@ -7,6 +7,11 @@ class MapMailerPreview < ActionMailer::Preview def access_request_email request = AccessRequest.first - MapMailer.access_request_email(request, request.map) + MapMailer.access_request_email(request) + end + + def access_approved_email + request = AccessRequest.first + MapMailer.access_approved_email(request) end end diff --git a/spec/models/access_request_spec.rb b/spec/models/access_request_spec.rb index 98490bf7..e8db280b 100644 --- a/spec/models/access_request_spec.rb +++ b/spec/models/access_request_spec.rb @@ -1,8 +1,7 @@ +# frozen_string_literal: true require 'rails_helper' RSpec.describe AccessRequest, type: :model do - include ActiveJob::TestHelper # enqueued_jobs - let(:access_request) { create(:access_request) } describe 'approve' do @@ -13,7 +12,7 @@ RSpec.describe AccessRequest, type: :model do it { expect(access_request.approved).to be true } it { expect(access_request.answered).to be true } it { expect(UserMap.count).to eq 1 } - it { expect(enqueued_jobs.count).to eq 1 } + it { expect(Mailboxer::Notification.count).to eq 1 } end describe 'deny' do @@ -24,6 +23,6 @@ RSpec.describe AccessRequest, type: :model do it { expect(access_request.approved).to be false } it { expect(access_request.answered).to be true } it { expect(UserMap.count).to eq 0 } - it { expect(enqueued_jobs.count).to eq 0 } + it { expect(Mailboxer::Notification.count).to eq 0 } end end