ruby-on-railsrubyactivemodelruby-on-rails-7

How to handle multiple belongs_to relations to the same model?


I am trying to build an invitation system for my app and I feel like there's a nice way to do what I need, yet I'm not sure about it.

I have a User model and I created a UserInvite model.

When an existing user sends out a new invite, a UserInvite is created. I am storing when the invite was sent, generate an invite token to make signing up easier and send an email to the invited user.

When the invite is accepted, I would also like to store the user id of the new user on the UserInvite object.

That is, a UserInvite belongs to both inviter and the invitee, and the latter is only being added to the object later on, not at creation, since I don't know the id of the to-be-created User.

What is the right way to model this relationship?

I'm using Rails 7.


Solution

  • If you look at Devise::Invitable as a reference then the answer is You Ain't Gonna Need It. It just adds columns to the user model to track invitations:

    create_table :users do
      ...
        ## Invitable
        t.string   :invitation_token
        t.datetime :invitation_created_at
        t.datetime :invitation_sent_at
        t.datetime :invitation_accepted_at
        t.integer  :invitation_limit
        t.integer  :invited_by_id
        t.string   :invited_by_type
      ...
    end
    add_index :users, :invitation_token, unique: true
    

    These columns are used to bypass several validations so that you can create a user record without a password - which is then "claimed" by updating the record with a password.

    If you want to do a separate table for invitations then there isn't really a clear need to store the invited users id either unless you actually plan on displaying or using the invitations in some way.

    The invitation will simply be used as a token when signing the user up and can then be considered spent and deletable. If you want to record who invited the user I would just add a inviter_id to the users table:

    class AddInviterToUsers < ActiveRecord::Migration[7.0]                                                                    
      def change                                                                                                                
        add_reference :users, :inviter, null: true, foreign_key: { to_table: :users }
      end
    end    
    
    class User < ApplicationRecord
      belongs_to :inviter, class_name: 'User',
                           optional: true
      has_many :invited_users, 
        class_name: 'User',
        foreign_key: :inviter_id
    end
    

    If you really want to go down the path of two columns pointing to the users table then you can do it with something like:

    class CreateUserInvitations < ActiveRecord::Migration[7.0]                                                                
      def change                                                                                                                
        create_table :user_invitations do |t|  
          t.string :token                                                                                   
          t.belongs_to :inviter, null: false, foreign_key: { to_table: :users }                                                                  
          t.belongs_to :invitee, null: true, foreign_key: { to_table: :users }                                                                                                                                                                                          
          t.timestamps                                                                                                          
        end                                                                                                                   
      end                                                                                                                   end    
    
    class UserInvitation < ApplicationRecord
      belongs_to :invitee, class_name: 'User',
                           optional: true
      belongs_to :inviter, class_name: 'User'
    end
    
    class User < ApplicationRecord
      has_many :user_invitations_as_inviter,
        class_name: 'UserInvitation',
        foreign_key: :inviter_id
      has_many :invited_users,
        through: :user_invitations_as_inviter,
        source: :invitee
      has_many :user_invitations_as_invitee,
        class_name: 'UserInvitation',
        foreign_key: :invitee_id
    end
    

    But again - YAGNI. This will add additional complexity and a UPDATE query.