homeASCIIcasts

393: Guest User Record 

(view original Railscast)

Let’s say that we have a to-do list application where the user can add tasks and mark them as complete. A task list is private for a user which means that a new user must first sign up for the app before they can try it out.

The home page of our to-do list application.

This is the page that a new user sees when they first visit the app with a description to try to convince them to try it. When they click the “Try it for free” button they see a sign-up form and at this point a lot of potential customers will leave as they won’t want to give their personal information just to try out an app. What can we do to improve this user experience? Instead of redirecting new users to a sign-up form we’ll create a temporary guest account that they can use to try the application. Later on they can sign up for a permanent account if they like it.

Handling Guest Users

The first question that comes to mind is should we store the guest user account in the database if it’s just temporary? Our application is currently structured so that a user has many tasks and we use this association in the TasksController to ensure that only the current user’s tasks are shown. While we could keep track of a guest’s tasks’ ids in a session this could get messy as our application grows and we have other associations with out User record. This would also mean that we had a lot of user_id foreign keys in our database that will be null and we’d have no way then of knowing if two tasks are shared by the same guest user and so on. Given all this we’ll keep track of all the guests in the database and to start we’ll add a boolean guest column to the users table.

$ rails g migration add_guest_to_users guest:boolean
$ rake db:migrate

Now we need to modify our application so that when a user clicks the “Try it for free” button a new guest user account is automatically created for them. The button currently redirects the user to the new_users_path so that the form is displayed, but instead we’ll point it to the users_path with a POST action.

/app/views/tasks/index.html.erb

<%= button_to "Try it for free!", users_path, method: :post %>

Clicking this button now triggers the UsersController’s create action. This is currently set up to accept parameters from a form and we’ll modify it so that if no parameters are passed in a new guest user is created, using a new new_guest class method on the User model.

/app/controllers/users_controller.rb

def create
  @user = params[:user] ? User.new(params[:user]) : User.new_guest
  if @user.save
    session[:user_id] = @user.id
    redirect_to root_url
  else
    render "new"
  end
end

In our User model we’ve written the authentication from scratch as we showed in episode 250. We use has_secure_password which Rails provides and which is a convenient way to add authentication to an application. If you’re using Devise or another authentication gem you’ll need to alter the technique we use here.

/app/models/user.rb

class User < ActiveRecord::Base
  has_many :tasks
  attr_accessible :username, :email, :password, :password_confirmation
  validates_presence_of :username, :email
  validates_uniqueness_of :username
  has_secure_password
  def self.new_guest
    new { |u| u.guest = true }
  end
end

In the new_guest method we create a new User and set its guest attribute to true. We do this inside a block because the guest attribute is protected from mass-assignment.

A guest user will now be created whenever we click that button, but what if we want this to work a little differently? Maybe we don’t want to override the UsersController’s create method or we want a guest user to always be available if we try to access the current user when one isn’t logged in. In these cases we can override the current_user method in the ApplicationController and have it fall back to creating a guest user if there is no current user_id session variable. We won’t take that approach here but it’s worth considering as a way to handle guest users.

Dealing With Validations

Let’s try our changes out. When we visit the home page then click the “Try it for free” button the UsersController attempts to create a guest user but that user fails the validation and so we’re redirected to the sign-up form.

Trying the app as a guest throws validation errors.

What we need is for the new_guest method to return a valid user record and there are multiple ways that we can do this. One option is to add fake data for the required fields so that the validation passes. This is certainly an option but if we have different authentication techniques, such as signing in through Twitter it’s harder to fake this data. This approach also means that we’re filling up the database with data that will never be used. Instead, we’ll modify the validations so that they’re conditional and not required if the current user is a guest.

/app/models/user.rb

validates_presence_of :username, :email, unless: :guest?
validates_uniqueness_of :username, allow_blank: true

When we click the button now the validations still fail as we haven’t supplied a password. This validation is added automatically by has_secure_password. In Rails 4 we’ll be able to pass in a validations option and set it to false so that these validations are skipped and then set them manually. Unfortunately this isn’t available in Rails 3 but the implementation for has_secure_password is surprisingly simple. The method is only around a dozen lines long and most of the logic is contained in a module called InstanceMethodsOnActivation which we can easily include manually. We can mimic this functionality and alter it to suit our needs. Once we’ve done that out model class looks like this:

/app/models/user.rb

class User < ActiveRecord::Base
  has_many :tasks
  attr_accessible :username, :email, :password, :password_confirmation
  validates_presence_of :username, :email, :password_digest, unless: :guest?
  validates_uniqueness_of :username, allow_blank: true
  validates_confirmation_of :password
  # override has_secure_password to customize validation until Rails 4.
  require 'bcrypt'
  attr_reader :password
  include ActiveModel::SecurePassword::InstanceMethodsOnActivation
  def self.new_guest
    new { |u| u.guest = true }
  end
end

Our class now mimics the functionality of has_secure_password but without the validations. This means that we can manually add the validations we want instead. If you’re using Devise and want to do something similar to this you can remove the Validatable module and add the validations manually or alternatively we can remove the email_changed? and password_required? methods so that they’re dependent on whether the current user is a guest.

We can try our application out now. If we click “Try it for free” now we’re taken to the home page and we can add items and mark them as complete without having created a user account.

Using the app as a guest.

There are still some minor issues with our application. For example the login status text says “Logged in as .”. We should instead give an indication that they’re logged in as a guest user and give them the chance to sign up for full membership. We can do this by editing the application’s layout file. This currently displays the current user’s username. We’ll display the name instead, which is a new method on the User model that we’ll write shortly.

/app/views/layouts/application.html.erb

<% if current_user %>
  Logged in as <%= current_user.name %>.
  <%= link_to "Log Out", logout_path %>
<% else %>
  <%= link_to "Log In", login_path %>
<% end %>

The new name method will check if the current user is a guest and, if so, display “Guest” instead of their username.

/app/models/user.rb

def name
  guest ? "Guest" : username
end

Now we need a way for guests to sign up and we’ll do this by adding a link to the layout that only shows if they’re a guest.

/app/views/layouts/application.html.erb

<% if current_user %>
  Logged in as <%= current_user.name %>.
  <% if current.user.guest? %>
    <%= link_to "Become a member", signup_path %>
  <% else %>
    <%= link_to "Log Out", logout_path %>
  <% end %>
<% else %>
  <%= link_to "Log In", login_path %>
<% end %>

We could make a logout link for guests too but we’d need to be a little more careful about that as guests can’t log back in.

Storing Guest Users’ Tasks

The trickiest part of all this is that we need to persist a guest user’s tasks when they become a member. There are several different ways that we can do this. One option is to change the create action so that it updates the current user record and removes the guest flag instead of creating a new record. Trying to juggle both new and existing user records in the same action can get a little messy, however, so we won’t try this. Instead we’ll move the associated data from the current user to the newly-created user if a current user exists and they are a guest.

/app/controllers/users_controller.rb

def create
  @user = params[:user] ? User.new(params[:user]) : User.new_guest
  if @user.save
    current_user.move_to(@user) if current_user && current.user.guest?
    session[:user_id] = @user.id
    redirect_to root_url
  else
    render "new"
  end
end

We call a move_to method on the User model to move the data while we’ll need to write. This will go through all the different associations and call update_all on them setting the user_id to the new user’s id.

/app/models/user.rb

def move_to(user)
  tasks.update_all(user_id: user.id)
end

We could also destroy the guest user here but we need to be careful if we do this as it might still have the associations still tied to it. For example if a user has many tasks and dependent is set to destroy this will end up destroying the tasks it’s associated with even though we’ve updated them to the new user. A better solution is to create a Rake task to destroy old guest accounts. This could look like this:

/lib/tasks/guests.rake

namespace :guests do
  desc "Remove guest accounts more than a week old."
  task :cleanup => :environment do
    User.where(guest: :true).where("created_at < ?", 1.week.ago).destroy_all
  end
end

The Rake task will go through all the User records and destroy all the guest accounts that were created over a week ago. If we do this it would be a good idea to notify the guest users that their accounts will be deleted after a week unless they upgrade to a full account. To have old guest accounts deleted automatically we could set up this Rake task as a cron job using the Whenever gem as demonstrated in episode 164. Now when a guest user clicks “Become a member” and fills in the signup form all their tasks are automatically moved over to their new account.