Topic: HOWTO: Create User Friendships with Rails
This post is about how i got a "friendship" system happening in my Rails App. It's by no means the most efficient way of doing things. But having said that, it works, and for a newbie like myself it creates a good starting point on which i could REFACTOR and optimise later on. I tried Google searching around about how to do this and checked out some Rails-related books, and the term that kept popping up was "self-referential many-to-many" relationships. That made no sense to me initially, and even when it did, i could not find examples of actual code that i could use to implement this that worked, or that i understood! Thankfully, though with much struggle, i managed to get it working on my app, here's how...
Please note that in my app i have a User Model, and i am using the RESTful Authentication plugin. To start with, let's think about the other Model(s) and specific relationships that you might need for this to work...
What you want is for each User to have many Friends, and each Friend to have and belong to many Users. But, that doesn't quite make sense, because a Friend is actually just another User. Essentially what we are trying to establish is a many-to-many relationship between all Users. That is, a "self-referential" relationship.
We can create this relationship using some simple declarations that i will mention shortly. In doing so we can avoid creating a new Friend Model, which would be pointless given that a Friend is just another User. Instead of a Friend Model, we will create a Friendship Model. This will act as a join Model between Users and "Friends" (the other Users). The friendships table will basically store all the friendships that exist between the Users. It will also store the status of that friendship. In this example the friendship status can be "REQUESTED", "PENDING", or "ACCEPTED". While we're at it, we will add a datetime/timestamp column to the table as well.
Ok, so first up:
generate Model Friendship
Go to your 00x_create_friendships.rb migration file. Edit your migration file in one of the following ways, either will work, the latter is the new Rails 2.0 way...
class CreateFriendships
def self.up
create_table :friendships do |t|
t.column :user_id, :integer
t.column :friend_id, :integer
t.column :status, :string
t.column :created_at, :datetime
end
enddef self.down
drop_table :friendships
end
end
Or the new way...
class CreateFriendships
def self.up
create_table :friendships do |t|
t.integer :user_id, :friend_id
t.string :status
t.timestamps
end
enddef self.down
drop_table :friendships
end
end
Go ahead and run the migration:
rake db:migrate
Add the following lines to your Friendship Model as shown.
Class Friendship
belongs_to :user
belongs_to :friend, :class_name => 'User', :foreign_key =>'friend_id'
end
Here we establish that a Friendship belongs to both a User and a Friend, and we specify that a Friend also belongs to the User class. Note again that there is no Friend Model!
Add the following lines to your User Model as shown.
Class User
has_many :friends, :through => :friendships, :conditions => "status = 'accepted'"
has_many :requested_friends, :through => :friendships, :source => :friend, :conditions => "status = 'requested'", :order => :created_at
has_many :pending_friends, :through => :friendships, :source => :friend, :conditions => "status = 'pending'", :order => :created_at
has_many :friendships, :dependent => :destroy
end
Here we are using our imaginary Friend model, and we tell Rails that this Model exists :through the Friendship join Model, and we are specifiying the :conditions as being for ACCEPTED friendships only. We are also defining the REQUESTED and PENDING friendships, who are also members of the imaginary Friend model, as indicated by the :source.
Let's create the controller that will make the magic happen.
generate Friends Controller
Add a new nested resource, eg:
map.resources :users do |user|
user.resources :friends
end
This will give us access to the '/user/1/friends' url, which we will use later when we edit the Friend's index.rhtml view page.
Now, edit the Friends controller.
class FriendsController
before_filter :login_required, :except => [:index, :show]def index
@user = User.find(params[:user_id])
enddef show
redirect_to user_path(params[:id])
enddef new
@friendship1 = Friendship.new
@friendship2 = Friendship.new
enddef create
@user = User.find(current_user)
@friend = User.find(params[:friend_id])
params[:friendship1] = {:user_id => @user.id, :friend_id => @friend.id, :status => 'requested'}
params[:friendship2] = {:user_id => @friend.id, :friend_id => @user.id, :status => 'pending'}
@friendship1 = Friendship.create(params[:friendship1])
@friendship2 = Friendship.create(params[:friendship2])
if @friendship1.save && @friendship2.save
redirect_to user_friends_path(current_user)
else
redirect_to user_path(current_user)
end
enddef update
@user = User.find(current_user)
@friend = User.find(params[:id])
params[:friendship1] = {:user_id => @user.id, :friend_id => @friend.id, :status => 'accepted'}
params[:friendship2] = {:user_id => @friend.id, :friend_id => @user.id, :status => 'accepted'}
@friendship1 = Friendship.find_by_user_id_and_friend_id(@user.id, @friend.id)
@friendship2 = Friendship.find_by_user_id_and_friend_id(@friend.id, @user.id)
if @friendship1.update_attributes(params[:friendship1]) && @friendship2.update_attributes(params[:friendship2])
flash[:notice] = 'Friend sucessfully accepted!'
redirect_to user_friends_path(current_user)
else
redirect_to user_path(current_user)
end
enddef destroy
@user = User.find(params[:user_id])
@friend = User.find(params[:id])
@friendship1 = @user.friendships.find_by_friend_id(params[:id]).destroy
@friendship2 = @friend.friendships.find_by_user_id(params[:id]).destroy
redirect_to user_friends_path(:user_id => current_user)
end
end
As you can see, when a friendship request is made, it will be stored 2 times in the database, ie for each pair of Users that the friendship involves. For example, for one User in a friendship pair, the record in the database will be that of a REQUESTED friendship, and for the other User it will be a PENDING friendship. Once the friendship is ACCEPTED, the friendship will have the ACCEPTED status in the record for both Users. Thus when we also update or destroy friendships, these actions must affect 2 records in the database.
In the Friends view directoty create an index.rhtml file and edit as follows.
My Friends:
<% unless @user == current_user %>
You do not have permission to access this page!
<% else %>
Current Friends:
<%= render :partial => 'friends/friends' %>
Requested Friends:
<%= render :partial => 'friends/requested_friends' %>
Pending Friends:
<%= render :partial => 'friends/pending_friends' %>
<% end %>
In the Friends view directory create the following partials.
_friends.rhtml:
<% unless @user.friends.empty? %>
<% @user.friends.each do |u| %>
<%= link_to u.login, user_path(u.id) %>
Since <%= u.created_at.to_s(:long) %>
<% if logged_in? && @user = current_user %>
<%= link_to '[Remove]', user_friend_path(:user_id => current_user, :id => u), :method => :delete, :confirm => 'Are you sure?' %>
<% end %>
<% end %>
<% else %>
None
<% end %>
_requested_friends.rhtml:
<% unless @user.requested_friends.empty? %>
<% @user.requested_friends.each do |u| %>
<%= link_to u.login, user_path(u.id) %>
Since <%= u.created_at.to_s(:long) %>
<% if logged_in? && @user = current_user %>
<%= link_to '[Cancel]', user_friend_path(:user_id => current_user, :id => u), :method => :delete, :confirm => 'Are you sure?' %>
<% end %>
<% end %>
<% else %>
None
<% end %>
_pending_friends.rhtml:
<% unless @user.pending_friends.empty? %>
<% @user.pending_friends.each do |u| %>
<%= link_to u.login, user_path(u.id) %>
Since <%= u.created_at.to_s(:long) %>
<% if logged_in? && @user = current_user %>
<%= link_to '[Accept]', user_friend_path(:user_id => current_user, :id => u), :method => :put, :confirm => 'Accept friend request! Are you sure?' %> |
<%= link_to '[Reject]', user_friend_path(:user_id => current_user, :id => u), :method => :delete, :confirm => 'Reject friend request! Are you sure?' %>
<% end %>
<% end %>
<% else %>
None
<% end %>
The Friends index.rhtml page for each User will list all their accepted, requested, and pending friendships. It will also allow 3 things:
1. The User will be able to REMOVE (destroy) an already accepted friendship.
2. The User will be able to CANCEL (destroy) a requested friendship.
3. The User will be able to ACCEPT (update) or REJECT (destroy) a pending friendship.
Basically we are just using the DESTROY and UPDATE actions in the Friends controller to get all this done.
Your Users Controller, which you have probably already set up yourself, will have a show action with the @user defined as follows:
class UsersController
def show
@user = User.find(params[:id])
end
end
What i then do is add the following code to the Users directory show.rhtml file (which for me is the User's Profile page), and again i will presume that you have some sort of User Profile page setup already in you app:
<% if logged_in? %>
<% unless @user == current_user || current_user.requested_friends.include?(@user) || current_user.friends.include?(@user) || current_user.pending_friends.include?(@user) %>
<% form_for(:friendship, :url => user_friends_path(:user_id => current_user.id, :friend_id => @user.id)) do |f| %>
<%= submit_tag "Add to My Friends" %>
<% end %>
<% end %>
<% end %>
This code also does 3 things:
1. It will only display the "Add to My Friends" button if you are a logged-in User.
2. It will only display the "Add to My Friends" button for Users who you are not friends with, and for whom you do not already have a requested or pending friendship with.
3. It will NOT display the "Add to My Friends" button on your own User profile, as you do not want to be friends with yourself!
That's it!
If you got any tips as to who i could REFACTOR any of the above code to make it look much more simpler and cleaner, please let me know! Right now i have skinny models and fat controllers, i think it probably should be the other way around...Also, if you are interested in helping me make this into a plugin, post a reply.
![]()
PJ.