Skip to main content

data consistency for invitation registration

General • Asked by Sean M

Hey!

At the moment I have a user and a product table and products are created by users. So the user can create his own product he is working on. User has_many :products and product belongs_to :user. Now I'd like to provide the user to be able to add more users to the product (who worked on product with him/her) even if those are not registered yet in the app.

So I will change the product and user table connection to has_many :users, through: :ProductUsers and has_many :products, through: :ProductUsers. That's easy.

When the user creates a product and adds the extra name and the email of the other users in the form the app will send out emails to those people to register (only if they are not registered). I can manage this far.

So here is my problem: Let's say the product is already created with the added names and emails and email invitation is sent out.

  • How can I make sure that after registration the new user entity will be assigned to the product his email/name is already assigned to by the guy who created the product?
  • How can I make sure the data will be consistent?
  • What kinda table should contain the email/name when product is created?
  • Is the approach for the table the good one or I should differentiate the creator and other users somehow for some authorization reasons?

I hope my question is clear :)


What I would recommend is that you create a join table for ProductUsers. This will be everyone who has access to the account. You'll duplicate the owner in this list so it's easier to query. You can keep the user_id on the product so that you know who is the owner of it.

When you create that form, you can use like devise_invitable in order to loop through those users and invite them. It creates User records in the database that are saved without passwords. Then it emails out links to set your password to everyone. Since it stores them in the database, you can use those IDs from those records to create the ProductUser join table between those users who don't have accounts yet and the product. As soon as they set their password, they'll automatically have access to the Product.

If you're not using Devise, you can use the same approach and build your own invitation system rather than using that gem plugin.

Make sense?


Hey Chris, thanks for the answer!

I'm using devise fortunately just didn't know devise invitable had this feature. It sounds straightforward!

As for the data model, let me sum it up just to make sure I get it:

user table:

has_many :product_users
has_many :products, through: :product_users
has_many :products #I guess this one is needed for the creator.

product table:

has_many :product_users
has_many :users, through: :product_users
belongs_to :user

product_users:

belongs_to :user
belongs_to :product

I think with this setup I will be able to call product.user to get the creator, and product.users to get everybody else.
So, is this what you were talking about? Could you also tell me why this approach is better, than let's say having a boolean field on ProductUsers to decide who the owner is? I guess in the boolean case it's easier to change this setup later on if I'd like to add more "owner" to the product.


Chris, could you answer my previous question? I've got some problems here. I can't figure out how to get the products you are the owner of. As you see there is has_many :products and has_many :products, through: :product_users. So I can't just query user.products to get back the owner.

I thought about a solution where I'm getting rid of has_many :products and using the following instance method in user class:

def owned_products
  Product.where("user_id = ?", self.id)
end

However I'm not sure if this is the rails convention and don't wanna run into some issues later on cuz my schema is screwed up.

Thanks!


You probably are in a situation where it doesn't matter either way you go. You can just use the ProductUser model and add a role to it if you want to add owners in there. That'll work really nicely. You can then just have:

class User
  has_many :product_users
  has_many :products, through: :product_users
  has_mnay :owned_products, ->{ where(product_users: {role: :owner}) }, through: :product_users, class_name: "Product"
end

This should allow you to have a single table for everything, you won't need the user_id on Product, and the only other thing with this is that when you create a Product you must make sure to create the ProductUser record and set the role to owner.


Thanks Chris! For the guys who might will use this later:

For simple has_many associations use class_name:

has_many :owned_products, ->{ where(product_users: {role: :owner}) }, 
through: :product_users, class_name: "Product"

For has_many through like this one use source instead, since it's not working with class_name:

has_many :owned_products, ->{ where(product_users: {role: :owner}) }, 
through: :product_users, source: :product


Chris, I just cut through this a few hours ago :D:D:D. I go and compare your solution to mine right away. Thanks for the episode!


You got it man! 👍 🎉


Chris, a great episode again! So I compared your solution to mine and my structure ended up being a bit more complex since I needed to provide users to accept/decline joining the product. So I created a separated AR class called product_invitation, which I think the correct solution to tackle the problem. My controller though is screwed up. The accept action is pretty simple, if the user accept the invitation then the product_user object gets created and some notifications get sent out. But my create action is terrible. You mentioned in the video there are some good refactoring patterns and I really like to see a new episode on that. In the meantime could you recommend me some of those patterns to discover and tell me how to get started with refactoring this?

So my create action:

  1. A 3 way conditional. I have to check if the user is already invited or already a team member. With these I can prevent users from joining more times than one to the team. If none of these then I send the invitations.

  2. A conditional again. Is the user already using the app? If he is then just send him a product invitation. If don't then send him devise invitation as well.

  3. If product invitation doesn't get saved send some error message. In this case only the email field can be empty so it's not a huge problem that I gotta call redirect with "Type an email address". So I use the redirect since for some reason I can't call render properly. If I call render :new it throws me on the create page (YES, create page and it loads it) and if I hit reload on that page then it tells me that route doesn't exist of course. I'm not sure what can cause these kind of issues (maybe devise or attr_accessor).

product_invitation.rb

attr_accessor :email
belongs_to :product
belongs_to :recipient, class_name: "User"
belongs_to :sender, class_name: "User"

product_invitation_controller.rb

def create
  authorize @product, :create_product_invitations?
  @recipient = User.find_by(email: params[:product_invitation][:email])
  if @recipient && @recipient.product_invitations.where("product_id = ?", @product.id).any?
    redirect_to :back, alert: "User already invited!"
  elsif @recipient && @product.users.where("user_id = ?", @recipient.id).any?
    redirect_to :back, alert: "User already a team member!"
  else
    @product_invitation = @product.product_invitations.new(product_invitations_params)
    unless @recipient
      @recipient = User.invite!({ email: params[:product_invitation][:email] }, @product.owner)
    end
    @product_invitation.sender = @product.owner
    @product_invitation.recipient = @recipient
    if @product_invitation.save
      Notification.create(recipient_id: @product_invitation.recipient_id,
        sender_id: @product_invitation.sender_id, notifiable: @product, action: "invited")
      ProductInvitationJob.perform_later(@product_invitation)
      redirect_to :back, notice: "Invitation sent!"
    else
      flash[:error] = "Type an email address!"
      redirect_to :back
    end
  end
end

def accept
  if @product_invitation.accepted == false
    @product_invitation.update_attribute(:accepted, true)
    ProductUser.create(product_id: @product.id, user_id: @product_invitation.recipient_id, role: "member")
    Notification.create(recipient_id: @product_invitation.sender_id,
                        sender_id: @product_invitation.recipient_id, notifiable: @product, action: "accepted" )
    redirect_to :back, notice: "Invitation accepted!"
  else
    redirect_to :back, notice: "Invitation is alredy accepted!"
  end
end

Login or Create An Account to join the conversation.

Subscribe to the newsletter

Join 27,623+ developers who get early access to new screencasts, articles, guides, updates, and more.

    By clicking this button, you agree to the GoRails Terms of Service and Privacy Policy.

    More of a social being? We're also on Twitter and YouTube.