diff --git a/.codeclimate.yml b/.codeclimate.yml index fbd96af2..a187069d 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -8,6 +8,7 @@ engines: enabled: true config: languages: + count_threshold: 3 # rule of three ruby: mass_threshold: 36 # default: 18 javascript: @@ -19,6 +20,8 @@ engines: enabled: true rubocop: enabled: true + exclude_fingerprints: + - 74f18007b920e8d81148d2f6a2756534 ratings: paths: - 'Gemfile.lock' diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..dddeed14 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,5 @@ + + + +============ +100BD/C = (100)(__)(__)/(__)=__ diff --git a/.travis.yml b/.travis.yml index 99b9a655..3dca7316 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,3 +22,4 @@ script: addons: code_climate: repo_token: 479d3bf56798fbc7fff3fc8151a5ed09e8ac368fd5af332c437b9e07dbebb44e + postgresql: "9.4" 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/api/v2/mappings_controller.rb b/app/controllers/api/v2/mappings_controller.rb index 4490e4af..186d6891 100644 --- a/app/controllers/api/v2/mappings_controller.rb +++ b/app/controllers/api/v2/mappings_controller.rb @@ -5,6 +5,27 @@ module Api def searchable_columns [] end + + def create + instantiate_resource + resource.user = current_user if current_user.present? + resource.updated_by = current_user if current_user.present? + authorize resource + create_action + respond_with_resource + end + + def update + resource.updated_by = current_user if current_user.present? + update_action + respond_with_resource + end + + def destroy + resource.updated_by = current_user if current_user.present? + destroy_action + head :no_content + end end 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/mappings_controller.rb b/app/controllers/mappings_controller.rb index de2c8ea1..86db023e 100644 --- a/app/controllers/mappings_controller.rb +++ b/app/controllers/mappings_controller.rb @@ -19,10 +19,10 @@ class MappingsController < ApplicationController @mapping = Mapping.new(mapping_params) authorize @mapping @mapping.user = current_user + @mapping.updated_by = current_user if @mapping.save render json: @mapping, status: :created - Events::NewMapping.publish!(@mapping, current_user) else render json: @mapping.errors, status: :unprocessable_entity end @@ -32,8 +32,10 @@ class MappingsController < ApplicationController def update @mapping = Mapping.find(params[:id]) authorize @mapping + @mapping.updated_by = current_user + @mapping.assign_attributes(mapping_params) - if @mapping.update_attributes(mapping_params) + if @mapping.save head :no_content else render json: @mapping.errors, status: :unprocessable_entity @@ -44,14 +46,7 @@ class MappingsController < ApplicationController def destroy @mapping = Mapping.find(params[:id]) authorize @mapping - - mappable = @mapping.mappable - if mappable.defer_to_map - mappable.permission = mappable.defer_to_map.permission - mappable.defer_to_map_id = nil - mappable.save - end - + @mapping.updated_by = current_user @mapping.destroy head :no_content 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/search_controller.rb b/app/controllers/search_controller.rb index c9fcc7db..c488c556 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -105,6 +105,7 @@ class SearchController < ApplicationController builder = builder.where(user: user) if user @maps = builder.order(:name) else + skip_policy_scope @maps = [] end @@ -120,10 +121,10 @@ class SearchController < ApplicationController term = term[7..-1] if term.downcase[0..6] == 'mapper:' search = term.downcase.strip + '%' - skip_policy_scope # TODO: builder = policy_scope(User) - builder = User.where('LOWER("name") like ?', search) + builder = policy_scope(User).where('LOWER("name") like ?', search) @mappers = builder.order(:name) else + skip_policy_scope @mappers = [] end render json: autocomplete_user_array_json(@mappers).to_json @@ -146,6 +147,7 @@ class SearchController < ApplicationController @synapses = @one + @two @synapses.sort! { |s1, s2| s1.desc <=> s2.desc }.to_a else + skip_policy_scope @synapses = [] 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/event.rb b/app/models/event.rb index 02c6d698..cf974664 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true class Event < ApplicationRecord - KINDS = %w(user_present_on_map conversation_started_on_map topic_added_to_map synapse_added_to_map).freeze + KINDS = %w(user_present_on_map conversation_started_on_map + topic_added_to_map topic_moved_on_map topic_removed_from_map + synapse_added_to_map synapse_removed_from_map + topic_updated synapse_updated).freeze - # has_many :notifications, dependent: :destroy belongs_to :eventable, polymorphic: true belongs_to :map belongs_to :user @@ -14,18 +16,12 @@ class Event < ApplicationRecord validates :kind, inclusion: { in: KINDS } validates :eventable, presence: true - # def notify!(user) - # notifications.create!(user: user) - # end - def belongs_to?(this_user) user_id == this_user.id end def notify_webhooks! - # group = self.discussion.group map.webhooks.each { |webhook| WebhookService.publish! webhook: webhook, event: self } - # group.webhooks.each { |webhook| WebhookService.publish! webhook: webhook, event: self } end handle_asynchronously :notify_webhooks! end diff --git a/app/models/events/new_mapping.rb b/app/models/events/new_mapping.rb deleted file mode 100644 index 889c69bc..00000000 --- a/app/models/events/new_mapping.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true -class Events::NewMapping < Event - # after_create :notify_users! - - def self.publish!(mapping, user) - create!(kind: mapping.mappable_type == 'Topic' ? 'topic_added_to_map' : 'synapse_added_to_map', - eventable: mapping, - map: mapping.map, - user: user) - end -end diff --git a/app/models/events/synapse_added_to_map.rb b/app/models/events/synapse_added_to_map.rb new file mode 100644 index 00000000..5afa885d --- /dev/null +++ b/app/models/events/synapse_added_to_map.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +class Events::SynapseAddedToMap < Event + # after_create :notify_users! + + def self.publish!(synapse, map, user, meta) + create!(kind: 'synapse_added_to_map', + eventable: synapse, + map: map, + user: user, + meta: meta) + end +end diff --git a/app/models/events/synapse_removed_from_map.rb b/app/models/events/synapse_removed_from_map.rb new file mode 100644 index 00000000..b64035dd --- /dev/null +++ b/app/models/events/synapse_removed_from_map.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +class Events::SynapseRemovedFromMap < Event + # after_create :notify_users! + + def self.publish!(synapse, map, user, meta) + create!(kind: 'synapse_removed_from_map', + eventable: synapse, + map: map, + user: user, + meta: meta) + end +end diff --git a/app/models/events/synapse_updated.rb b/app/models/events/synapse_updated.rb new file mode 100644 index 00000000..0d85cbe8 --- /dev/null +++ b/app/models/events/synapse_updated.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +class Events::SynapseUpdated < Event + # after_create :notify_users! + + def self.publish!(synapse, user, meta) + create!(kind: 'synapse_updated', + eventable: synapse, + user: user, + meta: meta) + end +end diff --git a/app/models/events/topic_added_to_map.rb b/app/models/events/topic_added_to_map.rb new file mode 100644 index 00000000..a3fa62cf --- /dev/null +++ b/app/models/events/topic_added_to_map.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +class Events::TopicAddedToMap < Event + # after_create :notify_users! + + def self.publish!(topic, map, user, meta) + create!(kind: 'topic_added_to_map', + eventable: topic, + map: map, + user: user, + meta: meta) + end +end diff --git a/app/models/events/topic_moved_on_map.rb b/app/models/events/topic_moved_on_map.rb new file mode 100644 index 00000000..08d01277 --- /dev/null +++ b/app/models/events/topic_moved_on_map.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +class Events::TopicMovedOnMap < Event + # after_create :notify_users! + + def self.publish!(topic, map, user, meta) + create!(kind: 'topic_moved_on_map', + eventable: topic, + map: map, + user: user, + meta: meta) + end +end diff --git a/app/models/events/topic_removed_from_map.rb b/app/models/events/topic_removed_from_map.rb new file mode 100644 index 00000000..2f03ec26 --- /dev/null +++ b/app/models/events/topic_removed_from_map.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +class Events::TopicRemovedFromMap < Event + # after_create :notify_users! + + def self.publish!(topic, map, user, meta) + create!(kind: 'topic_removed_from_map', + eventable: topic, + map: map, + user: user, + meta: meta) + end +end diff --git a/app/models/events/topic_updated.rb b/app/models/events/topic_updated.rb new file mode 100644 index 00000000..fd41a4d6 --- /dev/null +++ b/app/models/events/topic_updated.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +class Events::TopicUpdated < Event + # after_create :notify_users! + + def self.publish!(topic, user, meta) + create!(kind: 'topic_updated', + eventable: topic, + user: user, + meta: meta) + end +end diff --git a/app/models/map.rb b/app/models/map.rb index 86a89a24..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] } @@ -32,14 +32,19 @@ class Map < ApplicationRecord # Validate the attached image is image/jpg, image/png, etc validates_attachment_content_type :screenshot, content_type: /\Aimage\/.*\Z/ + after_save :update_deferring_topics_and_synapses, if: :permission_changed? + + delegate :count, to: :topics, prefix: :topic # same as `def topic_count; topics.count; end` + delegate :count, to: :synapses, prefix: :synapse + delegate :count, to: :contributors, prefix: :contributor + delegate :count, to: :stars, prefix: :star + + delegate :name, to: :user, prefix: true + def mappings topicmappings.or(synapsemappings) end - def mk_permission - Perm.short(permission) - end - def contributors User.where(id: mappings.map(&:user_id).uniq) end @@ -48,28 +53,10 @@ class Map < ApplicationRecord User.where(id: user_id).or(User.where(id: collaborators)) end - def topic_count - topics.length - end - - def synapse_count - synapses.length - end - - delegate :name, to: :user, prefix: true - def user_image user.image.url(:thirtytwo) end - def contributor_count - contributors.length - end - - def star_count - stars.length - end - def collaborator_ids collaborators.map(&:id) end @@ -131,4 +118,13 @@ class Map < ApplicationRecord end removed.compact end + + def update_deferring_topics_and_synapses + 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/mapping.rb b/app/models/mapping.rb index f7219008..99d23db0 100644 --- a/app/models/mapping.rb +++ b/app/models/mapping.rb @@ -6,6 +6,7 @@ class Mapping < ApplicationRecord belongs_to :mappable, polymorphic: true belongs_to :map, class_name: 'Map', foreign_key: 'map_id', touch: true belongs_to :user + belongs_to :updated_by, class_name: 'User' validates :xloc, presence: true, unless: proc { |m| m.mappable_type == 'Synapse' } @@ -16,6 +17,10 @@ class Mapping < ApplicationRecord delegate :name, to: :user, prefix: true + after_create :after_created + after_update :after_updated + before_destroy :before_destroyed + def user_image user.image.url end @@ -23,4 +28,35 @@ class Mapping < ApplicationRecord def as_json(_options = {}) super(methods: [:user_name, :user_image]) end + + def after_created + if mappable_type == 'Topic' + meta = {'x': xloc, 'y': yloc, 'mapping_id': id} + Events::TopicAddedToMap.publish!(mappable, map, user, meta) + elsif mappable_type == 'Synapse' + Events::SynapseAddedToMap.publish!(mappable, map, user, meta) + end + end + + def after_updated + if mappable_type == 'Topic' and (xloc_changed? or yloc_changed?) + meta = {'x': xloc, 'y': yloc, 'mapping_id': id} + Events::TopicMovedOnMap.publish!(mappable, map, updated_by, meta) + end + end + + def before_destroyed + if mappable.defer_to_map + mappable.permission = mappable.defer_to_map.permission + mappable.defer_to_map_id = nil + mappable.save + end + + meta = {'mapping_id': id} + if mappable_type == 'Topic' + Events::TopicRemovedFromMap.publish!(mappable, map, updated_by, meta) + elsif mappable_type == 'Synapse' + Events::SynapseRemovedFromMap.publish!(mappable, map, updated_by, meta) + end + end end diff --git a/app/models/synapse.rb b/app/models/synapse.rb index 37c9c72d..d14a18f4 100644 --- a/app/models/synapse.rb +++ b/app/models/synapse.rb @@ -22,6 +22,8 @@ class Synapse < ApplicationRecord where(topic1_id: topic_id).or(where(topic2_id: topic_id)) } + after_update :after_updated + delegate :name, to: :user, prefix: true def user_image @@ -36,11 +38,18 @@ class Synapse < ApplicationRecord end end - def calculated_permission - defer_to_map&.permission || permission + def as_json(_options = {}) + super(methods: [:user_name, :user_image, :collaborator_ids]) end - def as_json(_options = {}) - super(methods: [:user_name, :user_image, :calculated_permission, :collaborator_ids]) + def after_updated + attrs = ['desc', 'category', 'permission', 'defer_to_map_id'] + if attrs.any? {|k| changed_attributes.key?(k)} + new = self.attributes.select {|k| attrs.include?(k) } + old = changed_attributes.select {|k| attrs.include?(k) } + meta = new.merge(old) # we are prioritizing the old values, keeping them + meta['changed'] = changed_attributes.keys.select {|k| attrs.include?(k) } + Events::SynapseUpdated.publish!(self, user, meta) + end end end diff --git a/app/models/topic.rb b/app/models/topic.rb index 85f670c3..e5ea90ee 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -16,6 +16,7 @@ class Topic < ApplicationRecord belongs_to :metacode before_create :create_metamap? + after_update :after_updated validates :permission, presence: true validates :permission, inclusion: { in: Perm::ISSIONS.map(&:to_s) } @@ -75,12 +76,8 @@ class Topic < ApplicationRecord Pundit.policy_scope(user, maps).map(&:id) end - def calculated_permission - defer_to_map&.permission || permission - end - def as_json(options = {}) - super(methods: [:user_name, :user_image, :calculated_permission, :collaborator_ids]) + super(methods: [:user_name, :user_image, :collaborator_ids]) .merge(inmaps: inmaps(options[:user]), inmapsLinks: inmapsLinks(options[:user]), map_count: map_count(options[:user]), synapse_count: synapse_count(options[:user])) end @@ -129,15 +126,25 @@ class Topic < ApplicationRecord "Get: #{name}" end - def mk_permission - Perm.short(permission) + protected + + def create_metamap? + return unless (link == '') && (metacode.name == 'Metamap') + + @map = Map.create(name: name, permission: permission, desc: '', + arranged: true, user_id: user_id) + self.link = Rails.application.routes.url_helpers + .map_url(host: ENV['MAILER_DEFAULT_URL'], id: @map.id) end - protected - def create_metamap? - if link == '' and metacode.name == 'Metamap' - @map = Map.create({ name: name, permission: permission, desc: '', arranged: true, user_id: user_id }) - self.link = Rails.application.routes.url_helpers.map_url(:host => ENV['MAILER_DEFAULT_URL'], :id => @map.id) - end + def after_updated + attrs = ['name', 'desc', 'link', 'metacode_id', 'permission', 'defer_to_map_id'] + if attrs.any? {|k| changed_attributes.key?(k)} + new = self.attributes.select {|k| attrs.include?(k) } + old = changed_attributes.select {|k| attrs.include?(k) } + meta = new.merge(old) # we are prioritizing the old values, keeping them + meta['changed'] = changed_attributes.keys.select {|k| attrs.include?(k) } + Events::TopicUpdated.publish!(self, user, meta) end + 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..27f95861 100644 --- a/app/models/webhooks/slack/base.rb +++ b/app/models/webhooks/slack/base.rb @@ -14,49 +14,16 @@ Webhooks::Slack::Base = Struct.new(:webhook, :event) do 'something' end - def channel - webhook.channel - end - - def attachments - [{ - title: attachment_title, - text: attachment_text, - fields: attachment_fields, - fallback: attachment_fallback - }] - end + delegate :channel, to: :webhook alias_method :read_attribute_for_serialization, :send private - # def motion_vote_field - # { - # title: "Vote on this proposal", - # value: "#{proposal_link(eventable, "yes")} · " + - # "#{proposal_link(eventable, "abstain")} · " + - # "#{proposal_link(eventable, "no")} · " + - # "#{proposal_link(eventable, "block")}" - # } - # end - def view_map_on_metamaps(text = nil) "<#{map_url(event.map)}|#{text || event.map.name}>" end - # def view_discussion_on_loomio(params = {}) - # { value: discussion_link(I18n.t(:"webhooks.slack.view_it_on_loomio"), params) } - # end - - # def proposal_link(proposal, position = nil) - # discussion_link position || proposal.name, { proposal: proposal.key, position: position } - # end - - # def discussion_link(text = nil, params = {}) - # "<#{discussion_url(eventable.map, params)}|#{text || eventable.discussion.title}>" - # end - def eventable @eventable ||= event.eventable end @@ -65,12 +32,3 @@ Webhooks::Slack::Base = Struct.new(:webhook, :event) do @author ||= eventable.author end end - -# webhooks: -# slack: -# motion_closed: "*%{name}* has closed" -# motion_closing_soon: "*%{name}* has a proposal closing in 24 hours" -# motion_outcome_created: "*%{author}* published an outcome in *%{name}*" -# motion_outcome_updated: "*%{author}* updated the outcome for *%{name}*" -# new_motion: "*%{author}* started a new proposal in *%{name}*" -# view_it_on_loomio: "View it on Loomio" diff --git a/app/models/webhooks/slack/conversation_started_on_map.rb b/app/models/webhooks/slack/conversation_started_on_map.rb index daf2270e..6b6595ce 100644 --- a/app/models/webhooks/slack/conversation_started_on_map.rb +++ b/app/models/webhooks/slack/conversation_started_on_map.rb @@ -3,24 +3,4 @@ class Webhooks::Slack::ConversationStartedOnMap < Webhooks::Slack::Base def text "There is a live conversation starting on map *#{event.map.name}*. #{view_map_on_metamaps('Join in!')}" end - # TODO: it would be sweet if it sends it with the metacode as the icon_url - - def attachment_fallback - '' # {}"*#{eventable.name}*\n#{eventable.description}\n" - end - - def attachment_title - '' # proposal_link(eventable) - end - - def attachment_text - '' # "#{eventable.description}\n" - end - - def attachment_fields - [{ - title: 'nothing', - value: 'nothing' - }] # [motion_vote_field] - end end diff --git a/app/models/webhooks/slack/synapse_added_to_map.rb b/app/models/webhooks/slack/synapse_added_to_map.rb index 5157afa7..3d944878 100644 --- a/app/models/webhooks/slack/synapse_added_to_map.rb +++ b/app/models/webhooks/slack/synapse_added_to_map.rb @@ -1,25 +1,7 @@ # frozen_string_literal: true class Webhooks::Slack::SynapseAddedToMap < Webhooks::Slack::Base def text - "\"*#{eventable.mappable.topic1.name}* #{eventable.mappable.desc || '->'} *#{eventable.mappable.topic2.name}*\" was added as a connection to the map *#{view_map_on_metamaps}*" - end - - def attachment_fallback - '' # {}"*#{eventable.name}*\n#{eventable.description}\n" - end - - def attachment_title - '' # proposal_link(eventable) - end - - def attachment_text - '' # "#{eventable.description}\n" - end - - def attachment_fields - [{ - title: 'nothing', - value: 'nothing' - }] # [motion_vote_field] + connector = eventable.desc.empty? ? '->' : eventable.desc + "\"*#{eventable.topic1.name}* #{connector} *#{eventable.topic2.name}*\" was added as a connection by *#{event.user.name}* to the map *#{view_map_on_metamaps}*" end end diff --git a/app/models/webhooks/slack/synapse_removed_from_map.rb b/app/models/webhooks/slack/synapse_removed_from_map.rb new file mode 100644 index 00000000..06d31206 --- /dev/null +++ b/app/models/webhooks/slack/synapse_removed_from_map.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +class Webhooks::Slack::SynapseRemovedFromMap < Webhooks::Slack::Base + def text + connector = eventable.desc.empty? ? '->' : eventable.desc + # todo express correct directionality of arrows when desc is empty + "\"*#{eventable.topic1.name}* #{connector} *#{eventable.topic2.name}*\" was removed by *#{event.user.name}* as a connection from the map *#{view_map_on_metamaps}*" + end +end diff --git a/app/models/webhooks/slack/topic_added_to_map.rb b/app/models/webhooks/slack/topic_added_to_map.rb index d3a19760..4f726069 100644 --- a/app/models/webhooks/slack/topic_added_to_map.rb +++ b/app/models/webhooks/slack/topic_added_to_map.rb @@ -1,26 +1,7 @@ # frozen_string_literal: true class Webhooks::Slack::TopicAddedToMap < Webhooks::Slack::Base def text - "New #{eventable.mappable.metacode.name} topic *#{eventable.mappable.name}* was added to the map *#{view_map_on_metamaps}*" + "*#{eventable.name}* was added by *#{event.user.name}* to the map *#{view_map_on_metamaps}*" end # TODO: it would be sweet if it sends it with the metacode as the icon_url - - def attachment_fallback - '' # {}"*#{eventable.name}*\n#{eventable.description}\n" - end - - def attachment_title - '' # proposal_link(eventable) - end - - def attachment_text - '' # "#{eventable.description}\n" - end - - def attachment_fields - [{ - title: 'nothing', - value: 'nothing' - }] # [motion_vote_field] - end end diff --git a/app/models/webhooks/slack/topic_moved_on_map.rb b/app/models/webhooks/slack/topic_moved_on_map.rb new file mode 100644 index 00000000..dfe088ed --- /dev/null +++ b/app/models/webhooks/slack/topic_moved_on_map.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class Webhooks::Slack::TopicMovedOnMap < Webhooks::Slack::Base + def text + "*#{eventable.name}* was moved by *#{event.user.name}* on the map *#{view_map_on_metamaps}*" + end +end diff --git a/app/models/webhooks/slack/topic_removed_from_map.rb b/app/models/webhooks/slack/topic_removed_from_map.rb new file mode 100644 index 00000000..05a79c3b --- /dev/null +++ b/app/models/webhooks/slack/topic_removed_from_map.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +class Webhooks::Slack::TopicRemovedFromMap < Webhooks::Slack::Base + def text + "*#{eventable.name}* was removed by *#{event.user.name}* from the map *#{view_map_on_metamaps}*" + end +end diff --git a/app/models/webhooks/slack/user_present_on_map.rb b/app/models/webhooks/slack/user_present_on_map.rb index c3185e48..4cee2992 100644 --- a/app/models/webhooks/slack/user_present_on_map.rb +++ b/app/models/webhooks/slack/user_present_on_map.rb @@ -3,24 +3,4 @@ class Webhooks::Slack::UserPresentOnMap < Webhooks::Slack::Base def text "Mapper *#{event.user.name}* has joined the map *#{event.map.name}*. #{view_map_on_metamaps('Map with them')}" end - # TODO: it would be sweet if it sends it with the metacode as the icon_url - - def attachment_fallback - '' # {}"*#{eventable.name}*\n#{eventable.description}\n" - end - - def attachment_title - '' # proposal_link(eventable) - end - - def attachment_text - '' # "#{eventable.description}\n" - end - - def attachment_fields - [{ - title: 'nothing', - value: 'nothing' - }] # [motion_vote_field] - end end 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/message_policy.rb b/app/policies/message_policy.rb index c32e29ed..8a140788 100644 --- a/app/policies/message_policy.rb +++ b/app/policies/message_policy.rb @@ -17,7 +17,8 @@ class MessagePolicy < ApplicationPolicy delegate :show?, to: :resource_policy def create? - record.resource.present? && resource_policy.update? + # we have currently decided to let any map that is visible to someone be commented on by them + record.resource.present? && resource_policy.show? end def update? diff --git a/app/policies/synapse_policy.rb b/app/policies/synapse_policy.rb index 145f7432..e3190c18 100644 --- a/app/policies/synapse_policy.rb +++ b/app/policies/synapse_policy.rb @@ -2,11 +2,10 @@ class SynapsePolicy < ApplicationPolicy class Scope < Scope def resolve - visible = %w(public commons) - return scope.where(permission: visible) unless user + return scope.where(permission: %w(public commons)) unless user - scope.where(permission: visible) - .or(scope.where.not(defer_to_map_id: nil).where(defer_to_map_id: user.all_accessible_maps.map(&:id))) + scope.where(permission: %w(public commons)) + .or(scope.where(defer_to_map_id: user.all_accessible_maps.map(&:id))) .or(scope.where(user_id: user.id)) end end diff --git a/app/policies/topic_policy.rb b/app/policies/topic_policy.rb index b29d9b44..bc80f657 100644 --- a/app/policies/topic_policy.rb +++ b/app/policies/topic_policy.rb @@ -2,11 +2,10 @@ class TopicPolicy < ApplicationPolicy class Scope < Scope def resolve - visible = %w(public commons) - return scope.where(permission: visible) unless user + return scope.where(permission: %w(public commons)) unless user - scope.where(permission: visible) - .or(scope.where.not(defer_to_map_id: nil).where(defer_to_map_id: user.all_accessible_maps.map(&:id))) + scope.where(permission: %w(public commons)) + .or(scope.where(defer_to_map_id: user.all_accessible_maps.map(&:id))) .or(scope.where(user_id: user.id)) end end @@ -23,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/serializers/api/v2/mapping_serializer.rb b/app/serializers/api/v2/mapping_serializer.rb index 19e7318e..30c9bd7f 100644 --- a/app/serializers/api/v2/mapping_serializer.rb +++ b/app/serializers/api/v2/mapping_serializer.rb @@ -14,6 +14,7 @@ module Api def self.embeddable { user: {}, + updated_by: {}, map: {} } end diff --git a/app/serializers/webhook_serializer.rb b/app/serializers/webhook_serializer.rb index a2acf869..c1f0e266 100644 --- a/app/serializers/webhook_serializer.rb +++ b/app/serializers/webhook_serializer.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true class WebhookSerializer < ActiveModel::Serializer - attributes :text, :username, :icon_url # , :attachments + attributes :text, :username, :icon_url attribute :channel, if: :has_channel? def has_channel? 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/migrate/20161214140124_add_meta_to_events.rb b/db/migrate/20161214140124_add_meta_to_events.rb new file mode 100644 index 00000000..edbee3d6 --- /dev/null +++ b/db/migrate/20161214140124_add_meta_to_events.rb @@ -0,0 +1,5 @@ +class AddMetaToEvents < ActiveRecord::Migration[5.0] + def change + add_column :events, :meta, :json + end +end diff --git a/db/migrate/20161216174257_add_updated_by_to_mappings.rb b/db/migrate/20161216174257_add_updated_by_to_mappings.rb new file mode 100644 index 00000000..e28b8281 --- /dev/null +++ b/db/migrate/20161216174257_add_updated_by_to_mappings.rb @@ -0,0 +1,5 @@ +class AddUpdatedByToMappings < ActiveRecord::Migration[5.0] + def change + add_reference :mappings, :updated_by, foreign_key: {to_table: :users} + end +end diff --git a/db/schema.rb b/db/schema.rb index d16d4fb9..b30d597e 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: 20161216174257) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -49,6 +49,7 @@ ActiveRecord::Schema.define(version: 20161105160340) do t.integer "map_id" t.datetime "created_at" t.datetime "updated_at" + t.json "meta" t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable_type_and_eventable_id", using: :btree t.index ["map_id"], name: "index_events_on_map_id", using: :btree t.index ["user_id"], name: "index_events_on_user_id", using: :btree @@ -63,6 +64,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" @@ -75,10 +129,12 @@ ActiveRecord::Schema.define(version: 20161105160340) do t.datetime "updated_at", null: false t.integer "mappable_id" t.string "mappable_type" + t.integer "updated_by_id" t.index ["map_id", "synapse_id"], name: "index_mappings_on_map_id_and_synapse_id", using: :btree t.index ["map_id", "topic_id"], name: "index_mappings_on_map_id_and_topic_id", using: :btree t.index ["map_id"], name: "index_mappings_on_map_id", using: :btree t.index ["mappable_id", "mappable_type"], name: "index_mappings_on_mappable_id_and_mappable_type", using: :btree + t.index ["updated_by_id"], name: "index_mappings_on_updated_by_id", using: :btree t.index ["user_id"], name: "index_mappings_on_user_id", using: :btree end @@ -243,8 +299,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 +320,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 +336,9 @@ 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 "mappings", "users", column: "updated_by_id" add_foreign_key "tokens", "users" end diff --git a/doc/api/apis/mappings.raml b/doc/api/apis/mappings.raml index 00298387..af4965cb 100644 --- a/doc/api/apis/mappings.raml +++ b/doc/api/apis/mappings.raml @@ -1,6 +1,6 @@ #type: collection get: - is: [ embeddable: { embedFields: "user,map" }, orderable, pageable ] + is: [ embeddable: { embedFields: "user,updated_by,map" }, orderable, pageable ] securedBy: [ null, token, oauth_2_0, cookie ] responses: 200: @@ -31,7 +31,7 @@ post: /{id}: #type: item get: - is: [ embeddable: { embedFields: "user,map" } ] + is: [ embeddable: { embedFields: "user,updated_by,map" } ] securedBy: [ null, token, oauth_2_0, cookie ] responses: 200: diff --git a/doc/api/examples/mapping.json b/doc/api/examples/mapping.json index c4aa87bf..93d38bdb 100644 --- a/doc/api/examples/mapping.json +++ b/doc/api/examples/mapping.json @@ -6,6 +6,7 @@ "mappable_id": 1, "mappable_type": "Synapse", "user_id": 1, + "updated_by_id": 1, "map_id": 1 } } diff --git a/doc/api/examples/mappings.json b/doc/api/examples/mappings.json index 5a4a99c3..99f2e58d 100644 --- a/doc/api/examples/mappings.json +++ b/doc/api/examples/mappings.json @@ -8,6 +8,7 @@ "mappable_type": "Topic", "updated_at": "2016-03-25T08:44:07.152Z", "user_id": 1, + "updated_by_id": 1, "xloc": -271, "yloc": 22 }, @@ -19,6 +20,7 @@ "mappable_type": "Topic", "updated_at": "2016-03-25T08:44:13.907Z", "user_id": 1, + "updated_by_id": 1, "xloc": -12, "yloc": 61 }, @@ -30,6 +32,7 @@ "mappable_type": "Topic", "updated_at": "2016-03-25T08:44:19.333Z", "user_id": 1, + "updated_by_id": 1, "xloc": -93, "yloc": -90 }, @@ -40,7 +43,8 @@ "mappable_id": 1, "mappable_type": "Synapse", "updated_at": "2016-03-25T08:44:21.337Z", - "user_id": 1 + "user_id": 1, + "updated_by_id": 1 } ], "page": { diff --git a/doc/api/schemas/_mapping.json b/doc/api/schemas/_mapping.json index 8789c5ec..efd12c92 100644 --- a/doc/api/schemas/_mapping.json +++ b/doc/api/schemas/_mapping.json @@ -35,6 +35,12 @@ }, "user": { "$ref": "_user.json" + }, + "updated_by_id": { + "$ref": "_id.json" + }, + "updated_by": { + "$ref": "_user.json" } }, "required": [ @@ -56,6 +62,12 @@ { "required": [ "user_id" ] }, { "required": [ "user" ] } ] + }, + { + "oneOf": [ + { "required": [ "updated_by_id" ] }, + { "required": [ "updated_by" ] } + ] } ] } 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/Create.js b/frontend/src/Metamaps/Create.js index 7c3c4ff3..177e951f 100644 --- a/frontend/src/Metamaps/Create.js +++ b/frontend/src/Metamaps/Create.js @@ -140,7 +140,13 @@ const Create = { }, newTopic: { init: function() { - $('#topic_name').keyup(function() { + $('#topic_name').keyup(function(e) { + const ESC = 27 + + if (e.keyCode === ESC) { + Create.newTopic.hide() + } // if + Create.newTopic.name = $(this).val() }) @@ -301,13 +307,11 @@ const Create = { $('#synapse_desc').keyup(function(e) { const ESC = 27 - const BACKSPACE = 8 - const DELETE = 46 - if (e.keyCode === BACKSPACE && $(this).val() === '' || - e.keyCode === DELETE && $(this).val() === '' || - e.keyCode === ESC) { + + if (e.keyCode === ESC) { Create.newSynapse.hide() } // if + Create.newSynapse.description = $(this).val() }) @@ -317,6 +321,14 @@ const Create = { } }) + $('#synapse_desc').keydown(function(e) { + const TAB = 9 + if (Create.newSynapse.beingCreated && e.keyCode === TAB) { + e.preventDefault() + Synapse.createSynapseLocally() + } + }) + $('#synapse_desc').bind('typeahead:select', function(event, datum, dataset) { if (datum.id) { // if they clicked on an existing synapse get it Synapse.getSynapseFromAutocomplete(datum.id) diff --git a/frontend/src/Metamaps/DataModel/Synapse.js b/frontend/src/Metamaps/DataModel/Synapse.js index 5f2a6b88..e6a7f1c7 100644 --- a/frontend/src/Metamaps/DataModel/Synapse.js +++ b/frontend/src/Metamaps/DataModel/Synapse.js @@ -38,7 +38,6 @@ const Synapse = Backbone.Model.extend({ newOptions.success = function(model, response, opt) { if (s) s(model, response, opt) - model.set('calculated_permission', model.get('permission')) model.trigger('saved') if (permBefore === 'private' && model.get('permission') !== 'private') { @@ -85,7 +84,7 @@ const Synapse = Backbone.Model.extend({
  • ` }, authorizeToEdit: function(mapper) { - if (mapper && (this.get('calculated_permission') === 'commons' || this.get('collaborator_ids').includes(mapper.get('id')) || this.get('user_id') === mapper.get('id'))) return true + if (mapper && (this.get('permission') === 'commons' || this.get('collaborator_ids').includes(mapper.get('id')) || this.get('user_id') === mapper.get('id'))) return true else return false }, authorizePermissionChange: function(mapper) { diff --git a/frontend/src/Metamaps/DataModel/Topic.js b/frontend/src/Metamaps/DataModel/Topic.js index dff635f2..0d71c973 100644 --- a/frontend/src/Metamaps/DataModel/Topic.js +++ b/frontend/src/Metamaps/DataModel/Topic.js @@ -37,7 +37,6 @@ const Topic = Backbone.Model.extend({ newOptions.success = function(model, response, opt) { if (s) s(model, response, opt) - model.set('calculated_permission', model.get('permission')) model.trigger('saved') if (permBefore === 'private' && model.get('permission') !== 'private') { @@ -82,7 +81,7 @@ const Topic = Backbone.Model.extend({ authorizeToEdit: function(mapper) { if (mapper && (this.get('user_id') === mapper.get('id') || - this.get('calculated_permission') === 'commons' || + this.get('permission') === 'commons' || this.get('collaborator_ids').includes(mapper.get('id')))) { return true } else { 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/Import.js b/frontend/src/Metamaps/Import.js index 7d1b3aa3..deb71048 100644 --- a/frontend/src/Metamaps/Import.js +++ b/frontend/src/Metamaps/Import.js @@ -295,8 +295,7 @@ const Import = { permission: topicPermision, defer_to_map_id: deferToMapId, desc: desc || '', - link: link || '', - calculated_permission: Active.Map.get('permission') + link: link || '' }) DataModel.Topics.add(topic) diff --git a/frontend/src/Metamaps/JIT.js b/frontend/src/Metamaps/JIT.js index 3520b946..903cc11d 100644 --- a/frontend/src/Metamaps/JIT.js +++ b/frontend/src/Metamaps/JIT.js @@ -420,6 +420,8 @@ const JIT = { $('.rightclickmenu').remove() if (Mouse.boxStartCoordinates) { + Create.newSynapse.hide() + Create.newTopic.hide() Visualize.mGraph.busy = false Mouse.boxEndCoordinates = eventInfo.getPos() JIT.selectWithBox(e) @@ -434,7 +436,9 @@ const JIT = { } else if (node && !node.nodeFrom) { JIT.selectNodeOnRightClickHandler(node, e) } else { - // console.log('right clicked on open space') + // right click open space + Create.newSynapse.hide() + Create.newTopic.hide() } } }, diff --git a/frontend/src/Metamaps/Listeners.js b/frontend/src/Metamaps/Listeners.js index 470e8cbe..756227b1 100644 --- a/frontend/src/Metamaps/Listeners.js +++ b/frontend/src/Metamaps/Listeners.js @@ -2,6 +2,7 @@ import Active from './Active' import Control from './Control' +import DataModel from './DataModel' import JIT from './JIT' import Mobile from './Mobile' import Realtime from './Realtime' @@ -31,14 +32,27 @@ const Listeners = { break case 65: // if a or A is pressed if ((e.ctrlKey || e.metaKey) && onCanvas) { - Control.deselectAllNodes() - Control.deselectAllEdges() - + const nodesCount = Object.keys(Visualize.mGraph.graph.nodes).length + const selectedNodesCount = Selected.Nodes.length e.preventDefault() - Visualize.mGraph.graph.eachNode(function(n) { - Control.selectNode(n, e) + + // Hit Ctrl+A once to select all nodes + Control.deselectAllNodes() + Visualize.mGraph.graph.eachNode(node => { + Control.selectNode(node, e) }) + // Hitting Ctrl+A a second time will select all edges too + Control.deselectAllEdges() + if (nodesCount === selectedNodesCount) { + DataModel.Synapses.models.forEach(synapse => { + const topic1id = synapse.get('topic1_id') + const topic2id = synapse.get('topic2_id') + const edge = Visualize.mGraph.graph.edges[topic1id][topic2id] + Control.selectEdge(edge, e) + }) + } + Visualize.mGraph.plot() } 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/SynapseCard.js b/frontend/src/Metamaps/SynapseCard.js index b7b58821..d2feb03a 100644 --- a/frontend/src/Metamaps/SynapseCard.js +++ b/frontend/src/Metamaps/SynapseCard.js @@ -174,7 +174,7 @@ const SynapseCard = { add_perms_form: function(synapse) { // permissions - if owner, also allow permission editing - $('#editSynLowerBar').append('
    ') + $('#editSynLowerBar').append('
    ') // ability to change permission var selectingPermission = false diff --git a/frontend/src/Metamaps/TopicCard.js b/frontend/src/Metamaps/TopicCard.js index 71140bdd..3fa9a999 100644 --- a/frontend/src/Metamaps/TopicCard.js +++ b/frontend/src/Metamaps/TopicCard.js @@ -448,8 +448,8 @@ const TopicCard = { nodeValues.inmaps += '
  • ' + inmapsAr[i] + '
  • ' } } - nodeValues.permission = topic.get('calculated_permission') - nodeValues.mk_permission = topic.get('calculated_permission').substring(0, 2) + nodeValues.permission = topic.get('permission') + nodeValues.mk_permission = topic.get('permission').substring(0, 2) nodeValues.map_count = topic.get('map_count').toString() nodeValues.synapse_count = topic.get('synapse_count').toString() nodeValues.id = topic.isNew() ? topic.cid : topic.id diff --git a/frontend/src/Metamaps/Views/ChatView.js b/frontend/src/Metamaps/Views/ChatView.js index fd77864f..590dd775 100644 --- a/frontend/src/Metamaps/Views/ChatView.js +++ b/frontend/src/Metamaps/Views/ChatView.js @@ -3,13 +3,12 @@ import Backbone from 'backbone' import { Howl } from 'howler' import Autolinker from 'autolinker' -import _ from 'lodash' -import underscore from 'underscore' +import { clone, template as lodashTemplate } from 'lodash' import outdent from 'outdent' // TODO is this line good or bad // Backbone.$ = window.$ -const linker = new Autolinker({ newWindow: true, truncate: 50, email: false, phone: false, twitter: false }) +const linker = new Autolinker({ newWindow: true, truncate: 50, email: false, phone: false }) var Private = { messageHTML: outdent` @@ -41,12 +40,13 @@ var Private = {
    `, templates: function() { - underscore.templateSettings = { + const templateSettings = { interpolate: /\{\{(.+?)\}\}/g } - this.messageTemplate = underscore.template(Private.messageHTML) - this.participantTemplate = underscore.template(Private.participantHTML) + this.messageTemplate = lodashTemplate(Private.messageHTML, templateSettings) + + this.participantTemplate = lodashTemplate(Private.participantHTML, templateSettings) }, createElements: function() { this.$unread = $('
    ') @@ -147,7 +147,7 @@ var Private = { } return i } - var m = _.clone(message.attributes) + var m = clone(message.attributes) m.timestamp = new Date(m.created_at) @@ -176,7 +176,7 @@ var Private = { $(document).trigger(ChatView.events.message + '-' + this.room, [message]) }, addParticipant: function(participant) { - var p = _.clone(participant.attributes) + var p = clone(participant.attributes) if (p.self) { p.selfClass = 'is-self' p.selfName = '(me)' 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/frontend/src/index.js b/frontend/src/index.js index 67f69141..1d82af7c 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -1,5 +1,5 @@ // create global references -import _ from 'underscore' +import _ from 'lodash' import Metamaps from './Metamaps' window._ = _ diff --git a/package.json b/package.json index e33a6e0a..92d4ab25 100644 --- a/package.json +++ b/package.json @@ -18,46 +18,45 @@ }, "homepage": "https://github.com/metamaps/metamaps#readme", "dependencies": { - "attachmediastream": "1.4.1", - "autolinker": "0.17.1", - "babel-cli": "6.14.0", - "babel-loader": "6.2.5", - "babel-plugin-lodash": "^3.2.9", - "babel-plugin-transform-class-properties": "6.11.5", - "babel-preset-es2015": "6.14.0", - "babel-preset-react": "6.11.1", + "attachmediastream": "1.4.2", + "autolinker": "1.4.0", + "babel-cli": "6.18.0", + "babel-loader": "6.2.9", + "babel-plugin-lodash": "3.2.10", + "babel-plugin-transform-class-properties": "6.19.0", + "babel-preset-es2015": "6.18.0", + "babel-preset-react": "6.16.0", "backbone": "1.0.0", "clipboard-js": "git://github.com/devvmh/clipboard.js#patch-1", - "commonmark": "0.26.0", + "commonmark": "0.27.0", "csv-parse": "1.1.7", "getScreenMedia": "git://github.com/devvmh/getScreenMedia#patch-1", "hark": "git://github.com/devvmh/hark#patch-1", - "howler": "2.0.1", + "howler": "2.0.2", "json-loader": "0.5.4", - "lodash": "4.16.1", + "lodash": "4.17.2", "node-uuid": "1.4.7", - "outdent": "0.2.1", - "react": "15.3.2", - "react-dom": "15.3.2", - "react-dropzone": "3.6.0", - "redux": "^3.6.0", - "simplewebrtc": "2.2.0", + "outdent": "0.3.0", + "react": "15.4.1", + "react-dom": "15.4.1", + "react-dropzone": "3.7.3", + "redux": "3.6.0", + "simplewebrtc": "2.2.1", "socket.io": "1.3.7", - "underscore": "1.4.4", - "webpack": "1.13.2" + "webpack": "1.14.0" }, "devDependencies": { - "babel-eslint": "^6.1.2", + "babel-eslint": "^7.1.1", "chai": "^3.5.0", "circular-dependency-plugin": "^2.0.0", - "eslint": "^3.5.0", - "eslint-config-standard": "^6.2.0", - "eslint-plugin-promise": "^2.0.1", - "eslint-plugin-react": "^6.3.0", + "eslint": "^3.11.1", + "eslint-config-standard": "^6.2.1", + "eslint-plugin-promise": "^3.4.0", + "eslint-plugin-react": "^6.8.0", "eslint-plugin-standard": "^2.0.1", - "mocha": "^3.0.2" + "mocha": "^3.2.0" }, "optionalDependencies": { - "raml2html": "4.0.0-beta5" + "raml2html": "4.0.1" } } 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/mappings_controller_spec.rb b/spec/controllers/mappings_controller_spec.rb index 8d1c424d..e5f59db7 100644 --- a/spec/controllers/mappings_controller_spec.rb +++ b/spec/controllers/mappings_controller_spec.rb @@ -24,6 +24,7 @@ RSpec.describe MappingsController, type: :controller do post :create, params: { mapping: valid_attributes } + mapping.updated_by = controller.current_user expect(comparable(Mapping.last)).to eq comparable(mapping) end end 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/factories/mappings.rb b/spec/factories/mappings.rb index 1bcdf891..ec06b613 100644 --- a/spec/factories/mappings.rb +++ b/spec/factories/mappings.rb @@ -5,6 +5,7 @@ FactoryGirl.define do yloc 0 map user + association :updated_by, factory: :user association :mappable, factory: :topic factory :mapping_random_location do 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