Notifications ala Facebook (database implementation) - ruby-on-rails

I am wondering how Facebook implements their notifications system as I'm looking to do something similar.
FooBar commented on your status
Red1, Green2 and Blue3 commented on your photo
MegaMan and 5 others commented on your event
I can't have multiple notifications written into a single record, as eventually I will have actions associated with each notification. Also, in the view I'd like notifications to be rendered as expandable lists when a certain number of them exist for a single subject.
FooBar commented on your status (actions)
Red1, Green2 and Pink5 commented on your photo [+]
MegaMan and 3 others commented on your event [-]
MegaMan commented on your event (actions)
ProtoMan commented on your event (actions)
Bass commented on your event (actions)
DrWilly commented on your event (actions)
Cheers!
PS I am using postgres and rails BTW.

There are a number of ways to go about implementing this. It really depends on what kinds of notifications you want to cover and what information you need to collect about the notification to show it to the right user(s). If you are looking for a simple design that just covers notifications about posted comments, you could use a combination of polymorphic associations and observer callbacks:
class Photo < ActiveRecord::Base
# or Status or Event
has_many :comments, :as => :commentable
end
class Comment < ActiveRecord::Base
belongs_to :commenter
belongs_to :commentable, :polymorphic => true # the photo, status or event
end
class CommentNotification < ActiveRecord::Base
belongs_to :comment
belongs_to :target_user
end
class CommentObserver < ActiveRecord::Observer
observe :comment
def after_create(comment)
...
CommentNotification.create!(comment_id: comment.id,
target_user_id: comment.commentable.owner.id)
...
end
end
What's happening here is that each photo, status, event etc. has many comments. A Comment obviously belongs to a :commenter but also to a :commentable, which is either a photo, status, event or any other model that you want to allow comments for. You then have a CommentObserver that will observe your Comment model and do something whenever something happens with the Comment table. In this case, after a Comment is created, the observer will create a CommentNotification with the id of the comment and the id of the user who owns the thing that the comment is about (comment.commentable.owner.id). This would require that you implement a simple method :owner for each model you want to have comments for. So, for example, if the commentable is a photo, the owner would be the user who posted the photo.
This basic design should be enough to get you started, but note that if you want to create notifications for things other than comments, you could extend this design by using a polymorphic association in a more general Notification model.
class Notification < ActiveRecord::Base
belongs_to :notifiable, :polymorphic => true
belongs_to :target_user
end
With this design, you would then 'observe' all your notifiables (models that you want to create notifications for) and do something like the following in your after_create callback:
class GenericObserver < ActiveRecord::Observer
observe :comment, :like, :wall_post
def after_create(notifiable)
...
Notification.create!(notifiable_id: notifiable.id,
notifiable_type: notifiable.class.name,
target_user_id: notifiable.user_to_notify.id)
...
end
end
The only tricky part here is the user_to_notify method. All models that are notifiable would have to implement it in some way depending on what the model is. For example, wall_post.user_to_notify would just be the owner of the wall, or like.user_to_notify would be the owner of the thing that was 'liked'. You might even have multiple people to notify, like when notifying all the people tagged in a photo when someone comments on it.
Hope this helps.

I decided to post this as another answer because the first was getting ridiculously long.
To render the comment notifications as expandable lists as you note in your
examples, you first collect the comments that have notifications for some target user (don't forget to add has_one :notification to the Comment model).
comments = Comment.joins(:notification).where(:notifications => { :target_user_id => current_user.id })
Note that the use of joins here generates an INNER JOIN, so you correctly exclude any comments that don't have notifications (as some notifications might have been deleted by the user).
Next, you want to group these comments by their commentables so that you can create an expandable list for each commentable.
#comment_groups = comments.group_by { |c| "#{c.commentable_type}#{c.commentable_id}"}
which will generate a hash like
`{ 'Photo8' => [comment1, comment2, ...], 'Event3' => [comment1, ...], ... }`
that you can now use in your view.
In some_page_showing_comment_notifications.html.erb
...
<ul>
<% #comment_groups.each do |group, comments| %>
<li>
<%= render 'comment_group', :comments => comments, :group => group %>
</li>
<% end %>
</ul>
...
In _comment_group.html.erb
<div>
<% case comments.length %>
<% when 1 %>
<%= comments[0].commenter.name %> commented on your <%= comment[0].commentable_type.downcase %>.
<% when 2 %>
<%= comments[0].commenter.name %> and <%= comments[1].commenter.name %> commented on your <%= comment[0].commentable_type.downcase %>.
<% when 3 %>
<%= comments[0].commenter.name %>, <%= comments[1].commenter.name %> and <%= comments[2].commenter.name %> commented on your <%= comment[0].commentable_type.downcase %>
<% else %>
<%= render 'long_list_comments', :comments => comments, :group => group %>
<% end %>
</div>
In _long_list_comments.html.erb
<div>
<%= comments[0].commenter.name %> and <%= comments.length-1 %> others commented on your <%= comments[0].commentable_type %>.
<%= button_tag "+", class: "expand-comments-button", id: "#{group}-button" %>
</div>
<%= content_tag :ul, class: "expand-comments-list", id: "#{group}-list" do %>
<li>
<% comments.each do |comment| %>
# render each comment in the same way as before
<% end %>
</li>
<% end %>
Finally, it should be a simple matter to add some javascript to button.expand-comments-button to toggle the display property of ul.expand-comments-list. Each button and list has a unique id based on the comment group keys, so you can make each button expand the correct list.

So I have kinda made a little something before the second answer was posted but got a bit too busy to compose and put it here. And I'm still studying if I did the right thing here, if it would scale or how it would perform overall. I would like to hear all your ideas, suggestions, comments to the way I have implemented this. Here goes:
So I first created the tables as typical polymorphic tables.
# migration
create_table :activities do |t|
t.references :receiver
t.references :user
t.references :notifiable
t.string :notifiable_type #
t.string :type # Type of notification like 'comment' or 'another type'
...
end
# user.rb
class User < ActiveRecord::Base
has_many :notifications, :foreign_key => :receiver_id, :dependent => :destroy
end
# notification.rb
class Notification < ActiveRecord::Base
belongs_to :receiver, :class_name => 'User'
belongs_to :user
belongs_to :notifiable, :polymorphic => true
COMMENT = 'Comment'
ANOTHER_TYPE = 'Another Type'
end
So it's just this. Then inserts quite typically like when a user does a certain type of notification. So what's new that I found for psql is ARRAY_AGG which is an aggregate function that groups a certain field into a new field as an array (but string when it gets to rails). So how I'm getting the records are now like this:
# notification.rb
scope :aggregated,
select('type, notifiable_id, notifiable_type,
DATE(notifications.created_at),
COUNT(notifications.id) AS count,
ARRAY_AGG(users.name) AS user_names,
ARRAY_AGG(users.image) as user_images,
ARRAY_AGG(id) as ids').
group('type, notifiable_id, notifiable_type, DATE(notifications.created_at)').
order('DATE(notifications.created_at) DESC').
joins(:user)
This outputs something like:
type | notifiable_id | notifiable_type | date | count | user_names | user_images | id
"Comment"| 3 | "Status" |[date]| 3 | "['first', 'second', 'third']" |"['path1', 'path2', 'path3']" |"[1, 2, 3]"
And then in my notifications model again, I have this methods which basically just puts them back to an array and removes the non-uniques (so that a name won't be displayed twice in a certain aggregated notification):
# notification.rb
def array_of_aggregated_users
self.user_names[1..-2].split(',').uniq
end
def array_of_aggregated_user_images
self.user_images[1..-2].split(',').uniq
end
Then in my view I have something like this
# index.html.erb
<% #aggregated_notifications.each do |agg_notif| %>
<%
all_names = agg_notif.array_of_aggregated_users
all_images = agg_notif.array_of_aggregated_user_images
%>
<img src="<%= all_images[0] %>" />
<% if all_names.length == 1 %>
<%= all_names[0] %>
<% elsif all_names.length == 2 %>
<%= all_names[0] %> and <%= all_names[1] %>
<% elsif all_names.length == 3 %>
<%= all_names[0] %>, <%= all_names[1] %> and <%= all_names[2] %>
<% else %>
<%= all_names[0] %> and <%= all_names.length - 1 %> others
<% end %>
<%= agg_notif.type %> on your <%= agg_notif.notifiable_type %>
<% if agg_notif.count > 1 %>
<%= set_collapsible_link # [-/+] %>
<% else %>
<%= set_actions(ids[0]) %>
<% end %>
<% end %>

Related

How to properly render nested comments in rails?

I'm trying to make nested comments work properly in a rails 5 app and running into a lot of difficulty.
I'm building a question and answer site, and I have an Acomment model in which there are comments that belong to answers, and then there are comments that belong to other comments:
class Acomment < ApplicationRecord
belongs_to :answer
belongs_to :user
belongs_to :parent, class_name: "Acomment", optional: true
has_many :replies,
class_name: "Acomment", foreign_key: :parent_id, dependent: :destroy
end
I want to display all comments, and then all replies to comments, and replies to replies, etc. But I can't figure out how to make the nesting work properly.
In my view, inside an each loop, I'm rendering the _comment.html.erb partial:
<% answer.acomments.each do |comment| %>
<%= render :partial=>"comment", :object=>comment %>
<% end %>
Then, inside my _comment.html.erb partial, I'm displaying the comments, a reply link, and then rendering a partial for the replies to comments:
<div>
<%= comment.body %>
<%= link_to "Reply", new_acomment_path(:parent_id => comment, :answer_id => comment.answer) if comment.parent_id.blank? %>
<%= render partial: "reply", collection: comment.replies %>
</div>
Here's the _reply.html.erb partial:
<%= reply.body %>
<%= link_to "Reply", new_acomment_path(:parent_id => reply, :answer_id => reply.answer) %>
I'm running into two problems:
1) When I render the first partial, not only does it render the comments, but it also renders the replies at the same time (since they are also comments).
2) When I render the second partial (the replies), they are not nested within the comments where they are supposed to. Everything is out of order.
EDIT:
I expect to see this:
Comment 1
Comment on Comment 1
Comment on Comment on Comment 1
Comment 2
But instead this is what I'm seeing:
Comment 1
Comment on Comment 1
Comment 2
Comment on Comment 1
Comment on Comment on Comment 1
Comment on Comment on Comment 1
Any help would be greatly appreciated.
As Taryn wrote, you first need to filter the comments with no parent_id, which means they are first level comments to an answer.
views/answers/show.html.erb
<%= #answer.text %>
<h4>Comments</h4>
<% #answer.acomments.parents.each do |comment| %>
<%= render :partial=>"comment", locals: { comment: comment } %>
<% end %>
Then you need a recursive approach:
views/acomments/_comment.html.erb
<div>
<%= comment.body %>
<%= link_to "Reply", new_acomment_path(:parent_id => comment, :answer_id => comment.answer) if comment.parent_id.blank? %>
<% comment.replies.each do |reply|
<%= render :partial => "comment", locals: { comment: reply } %>
<% end %>
</div>
You need to provide some indentation so that it is clear the hierarchy between comments.
For the first part of your question, you'll probably need some scopes that help you distinguish between "parent comments" (ie comments that aren't replies) and "child comments" (replies).
something like:
class Acomment < ApplicationRecord
belongs_to :answer
belongs_to :user
belongs_to :parent, class_name: "Acomment", optional: true
has_many :replies, class_name: "Acomment", foreign_key: :parent_id, dependent: :destroy
scope :is_parent, where(parent_id: nil)
scope :is_child, where("parent_id IS NOT NULL")
end
You can use these eg below to render only parents in the main loop... with the intention each parent will render its own children.
<% answer.acomments.is_parent.each do |comment| %>
<%= render :partial=>"comment", :object=>comment %>
<% end %>
WRT your second question - I'll need a bit more info - I'll ask in comments above.
After:
So... My guess at what's happening is: you're just getting all the comments (as you mentioned) and they're being displayed in the outer loop (instead of just the parents being displayed int he outer loop) and they're in that order because that's the database-order for the comments.
Nothing is actually being displayed in the inner loop due to a default naming convention in partials.
If you use collection in a partial - Rails will feed each item into the partial... and then name the local variable after the name of the partial. In this case, you are trying to render a partial named "reply", but passing in a set of comments... so the partial is going to look for a variable called "reply".
I'd recommend you just reuse the same "comment" partial that you already use and instead render it this way:
<div>
<%= comment.body %>
<%= link_to "Reply", new_acomment_path(:parent_id => comment, :answer_id => comment.answer) if comment.parent_id.blank? %>
<%= render partial: "comment", collection: comment.replies %>
</div>
It will be recursive (just like Pablo's suggestion) and thus render comment threads by indenting for every set of sub-comments.
Looking at your conversation with pablo... it's possible that parents is a bad name for a scope... it sounds like it might be interfering with a ruby-method named the same thing that traverses up the class hierarchy... so perhaps it's a bad name - as such I'll rename the scope.

Rails, tips for solving this n+1?

I have an employee view, where are listed all skills, which are written in the skills table on my db. For every employee, all skills are displayed, just as want it to.
Employees and skills are related to each other as has many :through association.
class Employee < ApplicationRecord
has_many :employeeskillsets, foreign_key: "employee_id"
has_many :skills, through: :employeeskillsets
end
class Skill < ApplicationRecord
has_many :employeeskillsets, foreign_key: "skill_id"
has_many :employees, through: :employeeskillsets
end
class Employeeskillset < ApplicationRecord
belongs_to :employee, foreign_key: 'employee_id'
belongs_to :skill, foreign_key: 'skill_id'
end
All those skills are displayed as buttons, which are toggles to enable/disable that particular skill on that employee (per click a direct insert/delete, no extra commit needed).
<%= link_to apply_skill_employee_path(employee: #employee, skill_id: skill.id), method: :put, remote: :true do %>
<%= skill.name %></div><% end %>
But now, I want to the buttons to be shown colored, when you load the page. If the skill is already enabled, the buttoncolor should be green, else grey. And here begins my problem:
My app checks each skill with one separate select statement. I use the following code for this:
<%= link_to apply_skill_employee_path(employee: #employee, skill_id: skill.id), method: :put, remote: :true do %>
<% if #employee.skills.exists?(skill.id) %>
<div class="button skill e-true"><%= skill.name %></div>
<% else %>
<div class="button skill"><%= skill.name %></div>
<% end %>
I have already tried to use includes, but it seems, the exists? checks each skill independently.
Does anybody here have a suggestion, how I could solve this, by using one single select?
Thanks in advance, I hope I have mentioned everything, what is necessary.
Edit 1: I forgot to mention, that i render this through a partial (if that is important to know).
And here is the current used #employee var in the employees_controller.
#employee = Employee.find_by(id: params[:id])
Try plucking ids, and then check for include? as you don't need to fetch all attributes of skills
<% skills = #employee.skills.pluck(:id) %>
<%= link_to apply_skill_employee_path(employee: #employee, skill_id: skill.id), method: :put, remote: :true do %>
<% if skills.include?(skill.id) %>
<div class="button skill e-true"><%= skill.name %></div>
<% else %>
<div class="button skill"><%= skill.name %></div>
<% end %>
Since skill is also an active record model, you can use include? on the employee's skills collection to check if the employee has a particular skill.
#employee.skills.include?(skill)
This way you are free to use includes clause to eagerly load employees' skills.
This will not fire the additional query
<% if #employee.skill_ids.exists?(skill.id) %>
Also to avoid n+1 in the following line
apply_skill_employee_path(employee: #employee, skill_id: skill.id)
Make sure you are including skills
#employee = Employee.includes(:skills).where(......)
The Rails way is far simpler.
When you use the has_many macro in ActiveRecord it also creates a _ids method that can be used to add or remove relations with an array:
#employee.skills_ids = [1,2,3]
This also works with indirect assocations with the :through option.
You can use this together with the form collection helpers to create select or checkbox tags:
<%= form_for(#employee) do |f| %>
<%= f.label :skill_ids, 'Skills' %>
<%= f.collection_check_boxes(:skills_ids, Skill.all, :id, :name) %>
<% end %>
To avoid an extra query you can do a left outer join in the controller:
def edit
# left_outer_joins is new in Rails 5
# see https://blog.bigbinary.com/2016/03/24/support-for-left-outer-joins-in-rails-5.html
#employee.left_outer_joins(:skills).find(params[:id])
end
You also don't need a silly extra method in your controller for something that should be handled as a normal update. KISS.

Rails: Create Model and join table at the same time, has_many through

I have three Models:
class Question < ActiveRecord::Base
has_many :factor_questions
has_many :bigfivefactors, through: :factor_questions
accepts_nested_attributes_for :factor_questions
accepts_nested_attributes_for :bigfivefactors
end
class Bigfivefactor < ActiveRecord::Base
has_many :factor_questions
has_many :questions, through: :factor_questions
end
and my join-table, which holds not only the bigfivefactor_id and question_id but another integer-colum value.
class FactorQuestion < ActiveRecord::Base
belongs_to :bigfivefactor
belongs_to :question
end
Creating an new Question works fine, using in my _form.html.erb
<%= form_for(#question) do |f| %>
<div class="field">
<%= f.label :questiontext %><br>
<%= f.text_field :questiontext %>
</div>
<%= f.collection_check_boxes :bigfivefactor_ids, Bigfivefactor.all, :id, :name do |cb| %>
<p><%= cb.check_box + cb.text %></p>
<% end %>
This let's me check or uncheck as many bigfivefactors as i want.
But, as i mentioned before, the join model also holds a value.
Question:
How can I add a text-field next to each check-box to add/edit the 'value' on the fly?
For better understanding, i added an image
In the console, i was able to basically do this:
q= Question.create(questiontext: "A new Question")
b5 = Bigfivefactor.create(name: "Neuroticism")
q.bigfivefactors << FactorQuestion.create(question: q, bigfivefactor: b5, value: 10)
I also found out to edit my questions_controller:
def new
#question = Question.new
#question.factor_questions.build
end
But i have no idea how to put that into my view.
Thank you so much for your help!
Big Five Factors model considerations
It looks like your Bigfivefactors are not supposed to be modified with each update to question. I'm actually assuming these will be CMS controlled fields (such that an admin defines them). If that is the case, remove the accepts_nested_attributes for the bigfivefactors in the questions model. This is going to allow param injection that will change the behavior sitewide. You want to be able to link to the existing bigfivefactors, so #question.factor_questions.first.bigfivefactor.name is the label and #question.factor_questions.first.value is the value. Notice, these exist on different 'planes' of the object model, so there wont be much magic we can do here.
Parameters
In order to pass the nested attributes that you are looking for the paramater needs to look like this:
params = {
question: {
questiontext: "What is the average air speed velocity of a sparrow?",
factor_questions_attributes: [
{ bigfivefactor_id: 1, value: 10 },
{ bigfivefactor_id: 2, value: 5 } ]
}
}
Once we have paramaters that look like that, running Question.create(params[:question]) will create the Question and the associated #question.factor_questions. In order to create paramaters like that, we need html form checkbox element with a name "question[factor_questions_attributes][0][bigfivefactor_id]" and a value of "1", then a text box with a name of "question[factor_question_attributes][0][value]"
Api: nested_attributes_for has_many
View
Here's a stab at the view you need using fields_for to build the nested attributes through the fields for helper.
<%= f.fields_for :factor_questions do |factors| %>
<%= factors.collection_check_boxes( :bigfivefactor_id, Bigfivefactor.all, :id, :name) do |cb| %>
<p><%= cb.check_box + cb.text %><%= factors.text_field :value %></p>
<% end %>
<% end %>
API: fields_for
I'm not sure exactly how it all comes together in the view. You may not be able to use the built in helpers. You may need to create your own collection helper. #question.factor_questions. Like:
<%= f.fields_for :factor_questions do |factors| %>
<%= factors.check_box :_destroy, {checked => factors.object.persisted?}, '0','1' %> # display all existing checked boxes in form
<%= factors.label :_destroy, factors.object.bigfivefactor.name %>
<%= factors.text_box :value %>
<%= (Bigfivefactor.all - #question.bigfivefactors).each do |bff| %>
<%= factors.check_box bff.id + bff.name %><%= factors.text_field :value %></p> # add check boxes that aren't currently checked
<% end %>
<% end %>
I honestly know that this isn't functional as is. I hope the insight about the paramters help, but without access to an actual rails console, I doubt I can create code that accomplishes what you are looking for. Here's a helpful link: Site point does Complex nested queries

Rails 4 - How do I create an object from multiple models?

this is my 1st question so please feel free to ask if something is not clear and I will reply right away:
I'm working on a Rails 4 project - I want to be able to send a custom message from one user to another.
I have 3 models that should apply to this question: User, Track (meta info about a music track), TrackMessage.
the model TrackMessage has 4 fields: id, track_id, sender_id, recipient_id .
I want to be able to use the track_id and sender_id to create a single object that contains the track object the track_id refers to, and the user object the sender_id refers to.
Ideally, in show.html.erb I'd like to be able to call:
<% #myMessages.each do | m | %>
<p> <%= m.track.url %> </p>
<p> <%= m.track.name %> </p>
<p> <%= m.user.username %> </p>
<p> <%= m.track.url %> </p>
<% end %>
in the User controller I have:
def show
# fetch all of the messages that were sent to the current user
#trackMessages = TrackMessage.where(:recipient_id => current_user.id)
#myMessages = [ ]
*stuck on what happens next:
end
I would absolutely love the community's help on this. Thank you very much for your time!
I would set it up like this.
In your controller
def show
#track_messages = TrackMessage.includes(:track, :sender).where(:recipient_id => current_user.id)
end
TrackMessage model
Class TrackMessage
belongs_to :sender, class_name: 'User'
belongs_to :recipient, class_name: 'User'
belongs_to :track
delegate :name, to: :track, prefix: true
delegate :url, to: :track, prefix: true
delegate :username, to: :sender, prefix: true
end
Then in your view you should be able to do
<% #track_messages.each do | m | %>
<p> <%= m.track_url %> </p>
<p> <%= m.track_name %> </p>
<p> <%= m.sender_username %> </p>
<% end %>

Rails - nested forms to link has_one's where the child object has already been created

I have two models which are linked in a has_one / belongs_to association; Computer and Ipv6Address respectively.
I have pre-populated the Ipv6 table with all the entries that I want it to have, and I now need to have a drop-down list on the Computer new/edit forms to select an item from Ipv6 to associate it with.
Everything I've seen so far on this only seems to work when you are creating both objects at the same time on the new form and then subsequently editing them.
I've tried to set up my MVC's as per the examples I've found online, but I keep getting errors, as underneath these code excerpts:
Computer model:
class Computer < ActiveRecord::Base
accepts_nested_attributes_for :ipv6_address
has_one :ipv6_address
...
Ipv6Address model:
class Ipv6Address < ActiveRecord::Base
attr_accessible :computer_id, :ip_address
belongs_to :computer
...
Computer controller:
class ComputersController < ApplicationController
def new
#computer = Computer.new
#ipv6s = Ipv6Address.where('computer_id IS NULL').limit(5)
end
def edit
#computer = Computer.find(params[:id])
#ipv6s = Ipv6Address.where('computer_id = #{#computer.id} OR computer_id IS NULL').order('computer_id DESC').limit(5)
end
Computer new form:
<%= simple_form_for( #computer ) do |f| %>
<%= f.fields_for :ipv6_addresses do |v6| %>
<%= v6.input :ipv6_address, :collection => #ipv6s %>
<% end %>
<% f.button :submit %>
<% end %>
Error when browsing to computer new form:
NoMethodError in ComputersController#new
private method `key?' called for nil:NilClass
No line of code reference is given for this error, but it only appears when I have the nested form included in the computer new form.
Any ideas as to what is causing it or how I can better go about what I'm doing?
EDIT:
As it turns out, I needed to have accepts_nested_attributes_for :ipv6_address after the line has_one :ipv6_address in the Computer model.
That fixed the issue with the form loading.
As per Yarden's answer, I then also singularized all instances of "ipv6_address" so as to reflect the has_one relationship.
Once doing that in the new form, however, the ipv6 field completely disappeared. I'll open a new question with this one if I can't get it sorted out shortly.
Try adding ':', I think that may be the problem :
<%= f.fields_for :ipv6_addresses do |v6| %>
Also you forgot to add a '.' here:
#ipv6s = Ipv6Address.where('computer_id IS NULL').limit(5)
Edit after change:
The problem is that its only a has_one relationship so it doesnt know the plural of :ipv6_address as you stated in your model... you need to change it to :ipv6_address instead of :ipv6_addresses...
Also in your form change the :ipv6_address to the actual field which is :ip_address.
So overall your form should look like this:
<%= simple_form_for( #computer ) do |f| %>
<%= f.fields_for :ipv6_address do |v6| %>
<%= v6.input :ip_address, :collection => #ipv6s %>
<% end %>
<% f.button :submit %>
<% end %>

Resources