diff --git a/app/helpers/map_mailer_helper.rb b/app/helpers/map_mailer_helper.rb new file mode 100644 index 00000000..be95ea04 --- /dev/null +++ b/app/helpers/map_mailer_helper.rb @@ -0,0 +1,13 @@ +module MapMailerHelper + def access_approved_subject(map) + map.name + ' - access approved' + end + + def access_request_subject(map) + map.name + ' - request to edit' + end + + def invite_to_edit_subject(map) + map.name + ' - invited to edit' + end +end \ No newline at end of file diff --git a/app/helpers/topic_mailer_helper.rb b/app/helpers/topic_mailer_helper.rb new file mode 100644 index 00000000..81b87de0 --- /dev/null +++ b/app/helpers/topic_mailer_helper.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +module TopicMailerHelper + def added_to_map_subject(topic, map) + topic.name + ' was added to map ' + map.name + end + + def connected_subject(topic) + 'new synapse to topic ' + topic.name + end +end \ No newline at end of file diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 6c631e9a..112b28ab 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -10,15 +10,24 @@ class ApplicationMailer < ActionMailer::Base class << self def mail_for_notification(notification) case notification.notification_code - when MAILBOXER_CODE_ACCESS_REQUEST + when MAP_ACCESS_REQUEST request = notification.notified_object MapMailer.access_request(request) - when MAILBOXER_CODE_ACCESS_APPROVED + when MAP_ACCESS_APPROVED request = notification.notified_object MapMailer.access_approved(request) - when MAILBOXER_CODE_INVITE_TO_EDIT + when MAP_INVITE_TO_EDIT user_map = notification.notified_object MapMailer.invite_to_edit(user_map) + when TOPIC_ADDED_TO_MAP + event = notification.notified_object + TopicMailer.added_to_map(event, notification.recipients[0]) + when TOPIC_CONNECTED_1 + synapse = notification.notified_object + TopicMailer.connected(synapse, synapse.topic1, notification.recipients[0]) + when TOPIC_CONNECTED_2 + synapse = notification.notified_object + TopicMailer.connected(synapse, synapse.topic2, notification.recipients[0]) end end end diff --git a/app/mailers/map_mailer.rb b/app/mailers/map_mailer.rb index c14e9c65..6a207b23 100644 --- a/app/mailers/map_mailer.rb +++ b/app/mailers/map_mailer.rb @@ -1,22 +1,23 @@ # frozen_string_literal: true class MapMailer < ApplicationMailer + include MapMailerHelper default from: 'team@metamaps.cc' - def access_request(request) - @request = request - @map = request.map - mail(to: @map.user.email, subject: request.requested_text) - end - def access_approved(request) @request = request @map = request.map - mail(to: request.user, subject: request.approved_text) + mail(to: request.user.email, subject: access_approved_subject(@map)) + end + + def access_request(request) + @request = request + @map = request.map + mail(to: @map.user.email, subject: access_request_subject(@map)) end def invite_to_edit(user_map) @inviter = user_map.map.user @map = user_map.map - mail(to: user_map.user.email, subject: @map.invited_text) + mail(to: user_map.user.email, subject: invite_to_edit_subject(@map)) end end diff --git a/app/mailers/topic_mailer.rb b/app/mailers/topic_mailer.rb new file mode 100644 index 00000000..76464148 --- /dev/null +++ b/app/mailers/topic_mailer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +class TopicMailer < ApplicationMailer + include TopicMailerHelper + default from: 'team@metamaps.cc' + + def added_to_map(event, user) + @entity = event.eventable + @event = event + mail(to: user.email, subject: added_to_map_subject(@entity, event.map)) + end + + def connected(synapse, topic, user) + @entity = topic + @event = synapse + mail(to: user.email, subject: connected_subject(topic)) + end +end diff --git a/app/models/access_request.rb b/app/models/access_request.rb index 5c39b174..a26146ff 100644 --- a/app/models/access_request.rb +++ b/app/models/access_request.rb @@ -27,14 +27,6 @@ class AccessRequest < ApplicationRecord 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 protected diff --git a/app/models/events/topic_added_to_map.rb b/app/models/events/topic_added_to_map.rb index c485a5ce..c9259ca8 100644 --- a/app/models/events/topic_added_to_map.rb +++ b/app/models/events/topic_added_to_map.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Events class TopicAddedToMap < Event - # after_create :notify_users! + after_create :notify_users! def self.publish!(topic, map, user, meta) create!(kind: 'topic_added_to_map', @@ -10,5 +10,12 @@ module Events user: user, meta: meta) end + + def notify_users! + # in the future, notify followers of both the topic, and the map + NotificationService.notify_followers(eventable, TOPIC_ADDED_TO_MAP, self) + # NotificationService.notify_followers(map, MAP_RECEIVED_TOPIC, self) + end + handle_asynchronously :notify_users! end end diff --git a/app/models/events/topic_updated.rb b/app/models/events/topic_updated.rb index d52a4298..090cfcda 100644 --- a/app/models/events/topic_updated.rb +++ b/app/models/events/topic_updated.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Events class TopicUpdated < Event - # after_create :notify_users! + #after_create :notify_users! def self.publish!(topic, user, meta) create!(kind: 'topic_updated', @@ -9,5 +9,9 @@ module Events user: user, meta: meta) end + + def notify_users! + NotificationService.notify_followers(eventable, 'topic_updated', self) + end end end diff --git a/app/models/follow.rb b/app/models/follow.rb new file mode 100644 index 00000000..70ff6188 --- /dev/null +++ b/app/models/follow.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +class Follow < ApplicationRecord + + belongs_to :user + belongs_to :followed, polymorphic: true + has_one :follow_reason, dependent: :destroy + + validates :user, presence: true + validates :followed, presence: true + validates :user, uniqueness: { scope: :followed, message: 'This entity is already followed by this user' } + + after_create :add_subsetting + + private + + def add_subsetting + follow_reason = FollowReason.create!(follow: self) + end +end diff --git a/app/models/follow_reason.rb b/app/models/follow_reason.rb new file mode 100644 index 00000000..baf70a6a --- /dev/null +++ b/app/models/follow_reason.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +class FollowReason < ApplicationRecord + REASONS = %w(created commented contributed followed shared_on starred).freeze + + belongs_to :follow + + validates :follow, presence: true + + def has_reason + created || commented || contributed || followed || shared_on || starred + end +end \ No newline at end of file diff --git a/app/models/map.rb b/app/models/map.rb index 93137c70..a149a760 100644 --- a/app/models/map.rb +++ b/app/models/map.rb @@ -14,6 +14,8 @@ class Map < ApplicationRecord has_many :synapses, through: :synapsemappings, source: :mappable, source_type: 'Synapse' has_many :messages, as: :resource, dependent: :destroy has_many :stars, dependent: :destroy + has_many :follows, as: :followed, dependent: :destroy + has_many :followers, :through => :follows, source: :user has_many :access_requests, dependent: :destroy has_many :user_maps, dependent: :destroy @@ -37,6 +39,7 @@ class Map < ApplicationRecord # Validate the attached image is image/jpg, image/png, etc validates_attachment_content_type :screenshot, content_type: %r{\Aimage/.*\Z} + after_create :after_created_async after_update :after_updated after_save :update_deferring_topics_and_synapses, if: :permission_changed? @@ -135,15 +138,25 @@ class Map < ApplicationRecord Synapse.where(defer_to_map_id: id).update(permission: permission) end - def invited_text - name + ' - invited to edit' - end - protected + + def after_created_async + FollowService.follow(self, self.user, 'created') + # notify users following the map creator + end + handle_asynchronously :after_created_async def after_updated return unless ATTRS_TO_WATCH.any? { |k| changed_attributes.key?(k) } ActionCable.server.broadcast 'map_' + id.to_s, type: 'mapUpdated' end + def after_updated_async + if ATTRS_TO_WATCH.any? { |k| changed_attributes.key?(k) } + FollowService.follow(self, updated_by, 'contributed') + # NotificationService.notify_followers(self, 'map_updated', changed_attributes) + # or better yet publish an event + end + end + handle_asynchronously :after_updated_async end diff --git a/app/models/mapping.rb b/app/models/mapping.rb index 2f74e7ee..f8430bde 100644 --- a/app/models/mapping.rb +++ b/app/models/mapping.rb @@ -14,7 +14,9 @@ class Mapping < ApplicationRecord delegate :name, to: :user, prefix: true after_create :after_created + after_create :after_created_async after_update :after_updated + after_update :after_updated_async before_destroy :before_destroyed def user_image @@ -27,11 +29,10 @@ class Mapping < ApplicationRecord def after_created if mappable_type == 'Topic' + ActionCable.server.broadcast 'map_' + map.id.to_s, type: 'topicAdded', topic: mappable.filtered, mapping_id: id meta = { 'x': xloc, 'y': yloc, 'mapping_id': id } Events::TopicAddedToMap.publish!(mappable, map, user, meta) - ActionCable.server.broadcast 'map_' + map.id.to_s, type: 'topicAdded', topic: mappable.filtered, mapping_id: id elsif mappable_type == 'Synapse' - Events::SynapseAddedToMap.publish!(mappable, map, user, meta) ActionCable.server.broadcast( 'map_' + map.id.to_s, type: 'synapseAdded', @@ -40,8 +41,14 @@ class Mapping < ApplicationRecord topic2: mappable.topic2.filtered, mapping_id: id ) + Events::SynapseAddedToMap.publish!(mappable, map, user, nil) end end + + def after_created_async + FollowService.follow(map, user, 'contributed') + end + handle_asynchronously :after_created_async def after_updated if (mappable_type == 'Topic') && (xloc_changed? || yloc_changed?) @@ -50,6 +57,13 @@ class Mapping < ApplicationRecord ActionCable.server.broadcast 'map_' + map.id.to_s, type: 'topicMoved', id: mappable.id, mapping_id: id, x: xloc, y: yloc end end + + def after_updated_async + if (mappable_type == 'Topic') && (xloc_changed? || yloc_changed?) + FollowService.follow(map, updated_by, 'contributed') + end + end + handle_asynchronously :after_updated_async def before_destroyed if mappable.defer_to_map @@ -66,5 +80,6 @@ class Mapping < ApplicationRecord Events::SynapseRemovedFromMap.publish!(mappable, map, updated_by, meta) ActionCable.server.broadcast 'map_' + map.id.to_s, type: 'synapseRemoved', id: mappable.id, mapping_id: id end + FollowService.follow(map, updated_by, 'contributed') end end diff --git a/app/models/message.rb b/app/models/message.rb index d26ce9d6..203e8adb 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -6,6 +6,8 @@ class Message < ApplicationRecord delegate :name, to: :user, prefix: true after_create :after_created + #after_create :after_created_async + def user_image user.image.url @@ -19,4 +21,10 @@ class Message < ApplicationRecord def after_created ActionCable.server.broadcast 'map_' + resource.id.to_s, type: 'messageCreated', message: as_json end + + def after_created_async + FollowService.follow(resource, user, 'commented') + NotificationService.notify_followers(resource, 'map_message', self) + end + handle_asynchronously :after_created_async end diff --git a/app/models/star.rb b/app/models/star.rb index a49ae2b1..8a45a0c3 100644 --- a/app/models/star.rb +++ b/app/models/star.rb @@ -3,4 +3,19 @@ class Star < ActiveRecord::Base belongs_to :user belongs_to :map validates :map, uniqueness: { scope: :user, message: 'You have already starred this map' } + + #after_create :after_created_async + #before_destroy :before_destroyed + + protected + + def after_created_async + FollowService.follow(map, user, 'starred') + NotificationService.notify_followers(map, 'map_starred', self, 'created') + end + handle_asynchronously :after_created_async + + def before_destroyed + FollowService.remove_reason(map, user, 'starred') + end end diff --git a/app/models/synapse.rb b/app/models/synapse.rb index c74d8fc4..e8378f0e 100644 --- a/app/models/synapse.rb +++ b/app/models/synapse.rb @@ -26,7 +26,9 @@ class Synapse < ApplicationRecord } before_create :set_perm_by_defer + after_create :after_created_async after_update :after_updated + before_destroy :before_destroyed delegate :name, to: :user, prefix: true @@ -72,6 +74,12 @@ class Synapse < ApplicationRecord def set_perm_by_defer permission = defer_to_map.permission if defer_to_map end + + def after_created_async + follow_ids = NotificationService.notify_followers(topic1, TOPIC_CONNECTED_1, self) + NotificationService.notify_followers(topic2, TOPIC_CONNECTED_2, self, nil, follow_ids) + end + handle_asynchronously :after_created_async def after_updated if ATTRS_TO_WATCH.any? { |k| changed_attributes.key?(k) } @@ -85,4 +93,10 @@ class Synapse < ApplicationRecord end end end + + def before_destroyed + # hard to know how to do this yet, because the synapse actually gets destroyed + #NotificationService.notify_followers(topic1, 'topic_disconnected', self) + #NotificationService.notify_followers(topic2, 'topic_disconnected', self) + end end diff --git a/app/models/topic.rb b/app/models/topic.rb index 676ea934..82efb8bc 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -15,12 +15,17 @@ class Topic < ApplicationRecord has_many :mappings, as: :mappable, dependent: :destroy has_many :maps, through: :mappings + has_many :follows, as: :followed, dependent: :destroy + has_many :followers, :through => :follows, source: :user belongs_to :metacode before_create :set_perm_by_defer before_create :create_metamap? + after_create :after_created_async after_update :after_updated + after_update :after_updated_async + #before_destroy :before_destroyed validates :permission, presence: true validates :permission, inclusion: { in: Perm::ISSIONS.map(&:to_s) } @@ -149,6 +154,12 @@ class Topic < ApplicationRecord self.link = Rails.application.routes.url_helpers .map_url(host: ENV['MAILER_DEFAULT_URL'], id: @map.id) end + + def after_created_async + FollowService.follow(self, self.user, 'created') + # notify users following the topic creator + end + handle_asynchronously :after_created_async def after_updated if ATTRS_TO_WATCH.any? { |k| changed_attributes.key?(k) } @@ -162,4 +173,16 @@ class Topic < ApplicationRecord end end end + + def after_updated_async + if ATTRS_TO_WATCH.any? { |k| changed_attributes.key?(k) } + FollowService.follow(self, updated_by, 'contributed') + end + end + handle_asynchronously :after_updated_async + + def before_destroyed + # hard to know how to do this yet, because the topic actually gets destroyed + #NotificationService.notify_followers(self, 'topic_deleted', ?) + end end diff --git a/app/models/user.rb b/app/models/user.rb index 915d06db..e5fadaa9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -12,6 +12,10 @@ class User < ApplicationRecord has_many :stars has_many :user_maps, dependent: :destroy has_many :shared_maps, through: :user_maps, source: :map + has_many :follows, as: :followed + has_many :followers, through: :follows, source: :user + + has_many :following, class_name: 'Follow' after_create :generate_code diff --git a/app/models/user_map.rb b/app/models/user_map.rb index 65b39072..61b578ca 100644 --- a/app/models/user_map.rb +++ b/app/models/user_map.rb @@ -5,6 +5,7 @@ class UserMap < ApplicationRecord belongs_to :access_request after_create :after_created_async + before_destroy :before_destroyed def mark_invite_notifications_as_read Mailboxer::Notification.where(notified_object: self).find_each do |notification| @@ -15,11 +16,17 @@ class UserMap < ApplicationRecord protected def after_created_async + FollowService.follow(map, user, 'shared_on') if access_request NotificationService.access_approved(self.access_request) else NotificationService.invite_to_edit(self) end + # NotificationService.notify_followers(map, 'map_collaborator_added', self, 'shared_on') end handle_asynchronously :after_created_async + + def before_destroyed + FollowService.remove_reason(map, user, 'shared_on') + end end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb new file mode 100644 index 00000000..4fa6dd9d --- /dev/null +++ b/app/services/follow_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true +class FollowService + + + def self.follow(entity, user, reason) + + #return unless is_tester(user) + + follow = Follow.where(followed: entity, user: user).first_or_create + if FollowReason::REASONS.include?(reason) && !follow.follow_reason.read_attribute(reason) + follow.follow_reason.update_attribute(reason, true) + end + end + + def self.unfollow(entity, user) + Follow.where(followed: entity, user: user).destroy_all + end + + def self.remove_reason(entity, user, reason) + return unless FollowReason::REASONS.include?(reason) + follow = Follow.where(followed: entity, user: user).first + if follow + follow.follow_reason.update_attribute(reason, false) + if !follow.follow_reason.has_reason + follow.destroy + end + end + end + + protected + + def is_tester(user) + %w(connorturland@gmail.com devin@callysto.com chessscholar@gmail.com solaureum@gmail.com ishanshapiro@gmail.com).include?(user.email) + end +end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index c590a5ca..fa429f2e 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true class NotificationService + extend TopicMailerHelper + extend MapMailerHelper # for strip_tags include ActionView::Helpers::SanitizeHelper @@ -10,43 +12,68 @@ class NotificationService ) end - def self.get_template_for_event_type(event_type) - 'map_mailer/' + event_type - end - - def self.get_mailboxer_code_for_event_type(event_type) + def self.get_settings_for_event(entity, event_type, event) case event_type - when 'access_approved' - MAILBOXER_CODE_ACCESS_APPROVED - when 'access_request' - MAILBOXER_CODE_ACCESS_REQUEST - when 'invite_to_edit' - MAILBOXER_CODE_INVITE_TO_EDIT + when TOPIC_ADDED_TO_MAP + subject = added_to_map_subject(entity, event.map) + template = 'topic_mailer/added_to_map' + when TOPIC_CONNECTED_1 + subject = connected_subject(event.topic1) + template = 'topic_mailer/connected' + when TOPIC_CONNECTED_2 + subject = connected_subject(event.topic2) + template = 'topic_mailer/connected' end + + {template: template, subject: subject} + end + + def self.send_for_follows(follows, entity, event_type, event) + return if follows.length == 0 + settings = get_settings_for_event(entity, event_type, event) + # we'll prbly want to put the body into the actual loop so we can pass the current user in as a local + body = renderer.render(template: settings[:template], locals: { entity: entity, event: event }, layout: false) + follows.each{|follow| + # this handles email and in-app notifications, in the future, include push + follow.user.notify(settings[:subject], body, event, false, event_type, (follow.user.emails_allowed && follow.email), event.user) + # push could be handled with Actioncable to send transient notifications to the UI + # the receipt from the notify call could be used to link to the full notification + } + end + + def self.notify_followers(entity, event_type, event, reason_filter = nil, exclude_follows = nil) + follows = entity.follows.where.not(user_id: event.user.id) + + if !exclude_follows.nil? + follows = follows.where.not(id: exclude_follows) + end + + if reason_filter.class == String && FollowReason::REASONS.include?(reason_filter) + follows = follows.joins(:follow_reason).where('follow_reasons.' + reason_filter => true) + elsif reason_filter.class == Array + # TODO: throw an error here if all the reasons aren't valid + follows = follows.joins(:follow_reason).where(reason_filter.map{|r| "follow_reasons.#{r} = 't'"}.join(' OR ')) + end + send_for_follows(follows, entity, event_type, event) + return follows.map(&:id) end def self.access_request(request) - event_type = 'access_request' - template = get_template_for_event_type(event_type) - mailboxer_code = get_mailboxer_code_for_event_type(event_type) - body = renderer.render(template: template, locals: { map: request.map, request: request }, layout: false) - request.map.user.notify(request.requested_text, body, request, false, mailboxer_code, true, request.user) + subject = access_request_subject(request.map) + body = renderer.render(template: 'map_mailer/access_request', locals: { map: request.map, request: request }, layout: false) + request.map.user.notify(subject, body, request, false, MAP_ACCESS_REQUEST, true, request.user) end def self.access_approved(request) - event_type = 'access_approved' - template = get_template_for_event_type(event_type) - mailboxer_code = get_mailboxer_code_for_event_type(event_type) - body = renderer.render(template: template, locals: { map: request.map }, layout: false) - request.user.notify(request.approved_text, body, request, false, mailboxer_code, true, request.map.user) + subject = access_approved_subject(request.map) + body = renderer.render(template: 'map_mailer/access_approved', locals: { map: request.map }, layout: false) + request.user.notify(subject, body, request, false, MAP_ACCESS_APPROVED, true, request.map.user) end def self.invite_to_edit(user_map) - event_type = 'invite_to_edit' - template = get_template_for_event_type(event_type) - mailboxer_code = get_mailboxer_code_for_event_type(event_type) - body = renderer.render(template: template, locals: { map: user_map.map, inviter: user_map.map.user }, layout: false) - user_map.user.notify(user_map.map.invited_text, body, user_map, false, mailboxer_code, true, user_map.map.user) + subject = invite_to_edit_subject(user_map.map) + body = renderer.render(template: 'map_mailer/invite_to_edit', locals: { map: user_map.map, inviter: user_map.map.user }, layout: false) + user_map.user.notify(subject, body, user_map, false, MAP_INVITE_TO_EDIT, true, user_map.map.user) end # note: this is a global function, probably called from the rails console with some html body @@ -54,25 +81,9 @@ class NotificationService users = opts[:users] || User.all obj = opts[:obj] || nil sanitize_text = opts[:sanitize_text] || false - notification_code = opts[:notification_code] || MAILBOXER_CODE_MESSAGE_FROM_DEVS + notification_code = opts[:notification_code] || MESSAGE_FROM_DEVS send_mail = opts[:send_mail] || true sender = opts[:sender] || User.find_by(email: 'ishanshapiro@gmail.com') Mailboxer::Notification.notify_all(users, subject, body, obj, sanitize_text, notification_code, send_mail, sender) end - - def self.text_for_notification(notification) - case notification.notification_code - when MAILBOXER_CODE_ACCESS_APPROVED - map = notification.notified_object.map - 'granted your request to edit map ' + map.name + '' - when MAILBOXER_CODE_ACCESS_REQUEST - map = notification.notified_object.map - 'wants permission to map with you on ' + map.name + '  
Offer a response
' - when MAILBOXER_CODE_INVITE_TO_EDIT - map = notification.notified_object.map - 'gave you edit access to map ' + map.name + '' - when MAILBOXER_CODE_MESSAGE_FROM_DEVS - notification.subject - end - end end diff --git a/app/views/notifications/index.html.erb b/app/views/notifications/index.html.erb index a49c9602..77de7f65 100644 --- a/app/views/notifications/index.html.erb +++ b/app/views/notifications/index.html.erb @@ -7,7 +7,7 @@

Notifications