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 @@
-
+
- <%= 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