Create a dating website using Ruby on Rails
Part 1: Setting Up the Digital Cupid
Alright, future matchmaking mogul! So you've decided to build a dating app. Not because you're lonely (wink wink), but because you want to bring people together using the magic of Ruby on Rails. Let's create the next Tinder, Bumble, or whatever fruit-based dating app the kids are using these days.

Chapter 1: The Setup - Installing Your Wingman
First things first - let's get our digital wingman ready to whisper sweet nothings in JSON format.
# Terminal love spells
rails new cupid_arrows --database=postgresql
cd cupid_arrows
Why PostgreSQL? Because SQLite is like going on a date in sweatpants - fine for development, but you'll want to dress up for production. Also, PostgreSQL has better support for array types, which we'll need when storing people's questionable taste in music.
# Gemfile - adding our dating toolkit
gem 'devise', '~> 4.8' # Because everyone needs authentication in their life
gem 'faker', '~> 2.18' # For generating fake people (sadly, more reliable than real ones)
gem 'bulma-rails', '~> 0.9.1' # Because Bootstrap is too mainstream for our cool dating app
Run bundle install and watch as your terminal downloads potential love (or at least the tools to facilitate it).
Chapter 2: User Model - Because Even Digital Cupid Needs Profiles
Let's create our User model. Think of this as building the perfect digital person - except they'll probably lie about their height.
rails generate devise User
Now let's spice up our user model with some actual dating profile fields:
rails generate migration AddDatingFieldsToUsers
# db/migrate/[timestamp]_add_dating_fields_to_users.rb
class AddDatingFieldsToUsers < ActiveRecord::Migration[6.1]
def change
add_column :users, :name, :string
add_column :users, :age, :integer
add_column :users, :bio, :text
add_column :users, :location, :string
add_column :users, :height, :integer # in centimeters, because we're fancy
add_column :users, :gender, :string
add_column :users, :looking_for, :string
add_column :users, :interests, :string, array: true, default: []
add_column :users, :profile_picture, :string
add_column :users, :verified, :boolean, default: false # catfish prevention
end
end
Run rails db:migrate and watch as your database grows up and starts looking for love.
Now let's make our User model actually useful:
# app/models/user.rb
class User < ApplicationRecord
# Devise modules - the bouncers of our dating club
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
# Validations - because "asldkfj" isn't a valid name (probably)
validates :name, presence: true, length: { minimum: 2 }
validates :age, numericality: { greater_than_or_equal_to: 18, less_than: 120 }
validates :bio, length: { maximum: 500 } # No war and peace novels, please
validates :height, numericality: { greater_than: 100, less_than: 250 }, allow_nil: true
# Enums for gender - keeping it simple for now
enum gender: { male: 'male', female: 'female', non_binary: 'non_binary' }
# Scopes - for when you're feeling picky
scope :verified, -> { where(verified: true) }
scope :by_age_range, ->(min, max) { where(age: min..max) }
scope :by_gender, ->(gender) { where(gender: gender) }
# The magic method - find potential matches!
def potential_matches
User.where(looking_for: gender)
.where(gender: looking_for)
.where.not(id: id) # Because narcissism isn't a good look
end
def display_height
return "Not specified" unless height
feet = (height / 30.48).floor
inches = ((height % 30.48) / 2.54).round
"#{feet}'#{inches}\""
end
end
Chapter 3: Making Profiles Actually Look Date-able
Let's create a profiles controller so people can actually see each other:
rails generate controller Profiles show edit update
# app/controllers/profiles_controller.rb
class ProfilesController < ApplicationController
before_action :authenticate_user! # No peeking unless you're logged in!
before_action :set_user, only: [:show, :edit, :update]
def show
# This is where the magic happens (or doesn't, depending on the profile)
end
def edit
# Where users lie about their job and hobbies
end
def update
if @user.update(user_params)
redirect_to profile_path, notice: "Profile updated! Looking good! ๐"
else
render :edit, alert: "Oops! Something went wrong. Maybe try being more interesting?"
end
end
private
def set_user
@user = current_user # For now, users can only see/edit their own profile
end
def user_params
params.require(:user).permit(
:name, :age, :bio, :location, :height,
:gender, :looking_for, :profile_picture,
interests: [] # Array parameter for interests
)
end
end
Now let's make the profile view actually worth looking at:
<%# app/views/profiles/show.html.erb %>
<div class="container">
<div class="box">
<article class="media">
<div class="media-left">
<figure class="image is-128x128">
<% if @user.profile_picture %>
<%= image_tag @user.profile_picture, class: "is-rounded" %>
<% else %>
<img src="https://via.placeholder.com/128x128/FF69B4/FFFFFF?text=โค๏ธ" class="is-rounded" alt="Default profile">
<% end %>
</figure>
</div>
<div class="media-content">
<div class="content">
<p>
<strong><%= @user.name %>, <%= @user.age %></strong>
<% if @user.verified %>
<span class="tag is-success">โ
Verified</span>
<% end %>
<br>
<small><%= @user.location %></small>
<br>
<%= @user.bio || "This person is mysterious... too mysterious." %>
</p>
<div class="tags">
<% if @user.interests.any? %>
<% @user.interests.each do |interest| %>
<span class="tag is-primary"><%= interest %></span>
<% end %>
<% else %>
<span class="tag is-light">No interests listed (how boring!)</span>
<% end %>
</div>
<div class="field is-grouped">
<p class="control">
<%= link_to "Edit Profile", edit_profile_path, class: "button is-primary" %>
</p>
<p class="control">
<%= link_to "Find Matches", matches_path, class: "button is-info" %>
</p>
</div>
</div>
</div>
</article>
</div>
</div>
Chapter 4: Seeding the Database with Fake Love
No dating app is complete without fake profiles! Let's create some imaginary friends:
# db/seeds.rb
puts "Creating fake love interests... because real ones are hard to find!"
# Clear existing users (but not the admin!)
User.where.not(email: 'admin@cupidarrows.com').destroy_all
# Create some sample users with Faker
50.times do |i|
gender = [:male, :female, :non_binary].sample
looking_for = [:male, :female, :non_binary].sample
# Make sure people aren't TOO picky
looking_for = [looking_for, :male, :female].sample if rand < 0.3
user = User.create!(
email: Faker::Internet.unique.email,
password: 'password123', # Insecure but convenient for development
name: Faker::Name.name,
age: rand(18..45),
bio: Faker::Lorem.paragraph(sentence_count: 2),
location: Faker::Address.city,
height: rand(150..200),
gender: gender,
looking_for: looking_for,
interests: Faker::Hobby.phrases.sample(rand(3..6)),
verified: rand < 0.7 # 70% of profiles are "verified"
)
puts "Created #{user.name} - #{user.gender} looking for #{user.looking_for}"
end
puts "Created #{User.count} potential matches! Time to swipe right!"
Run rails db:seed and watch as your app fills up with imaginary people who will never ghost you!
Chapter 5: Routes - The Pathways to Love
Let's set up our routes so people can actually navigate this love maze:
# config/routes.rb
Rails.application.routes.draw do
devise_for :users
# Profile routes
resource :profile, only: [:show, :edit, :update]
# Future routes for our dating features
resources :matches, only: [:index] do
collection do
post ':id/like', to: 'matches#like', as: :like
post ':id/pass', to: 'matches#pass', as: :pass
end
end
# The holy grail - the root path
root to: "profiles#show"
end
What We've Built So Far:
- โ A basic Rails app with user authentication
- โ User profiles with dating-specific fields
- โ A profile viewing/editing system
- โ Fake data to play with
- โ Routes for our future features
Coming Up in Part 2:
In our next thrilling installment, we'll build:
- The swiping mechanism (the digital equivalent of awkward eye contact across a bar)
- Matching logic (when two people both decide to lower their standards)
- Real-time messaging (because "u up?" needs to happen in real-time)
- Photo uploads (so people can carefully curate their most flattering angles)
Remember, in the world of dating apps, the code might be perfect, but the humans using it... well, they're a work in progress. Happy coding, you romantic developer, you! โค๏ธ
Pro Tip: Don't test your dating app by actually dating the users. That's like a chef eating all their own food - tempting, but bad for business!
Let's Build Love (or Something Like It): A Rails Dating App Manual
Part 2: Swiping, Matching, and Digital Heart Palpitations
Welcome back, you beautiful matchmaker! In Part 1, we built the digital equivalent of an empty bar. Now it's time to fill it with awkward glances, questionable pickup lines, and the sweet sound of mutual matches. Let's build the features that'll make hearts race (or at least make thumbs tired from swiping).
Chapter 6: The Swipe Mechanism - Judging Books by Their Covers
First, let's generate our matching system. Because what's love without a little superficial judgment?
rails generate model Like user:references target_user:references liked:boolean
# db/migrate/[timestamp]_create_likes.rb
class CreateLikes < ActiveRecord::Migration[6.1]
def change
create_table :likes do |t|
t.references :user, null: false, foreign_key: { to_table: :users }
t.references :target_user, null: false, foreign_key: { to_table: :users }
t.boolean :liked
t.timestamps
t.index [:user_id, :target_user_id], unique: true # No double-dipping on likes!
end
end
end
Run rails db:migrate and watch as your database learns the language of love (and rejection).
Now let's make our models understand relationships:
# app/models/like.rb
class Like < ApplicationRecord
belongs_to :user
belongs_to :target_user, class_name: 'User'
validates :user_id, uniqueness: { scope: :target_user_id, message: "Already swiped on this profile! Make up your mind!" }
# Scopes for our swiping logic
scope :likes, -> { where(liked: true) }
scope :passes, -> { where(liked: false) }
after_create :check_for_match
after_create :send_notification_if_match
private
def check_for_match
# The magic moment - do they like us back?
mutual_like = Like.find_by(user: target_user, target_user: user, liked: true)
return unless mutual_like
# Create a match! ๐
Match.find_or_create_by(user1: user, user2: target_user)
end
def send_notification_if_match
# We'll implement this later when we add notifications
# For now, just imagine the excitement
puts "IT'S A MATCH! ๐" if Like.exists?(user: target_user, target_user: user, liked: true)
end
end
# app/models/user.rb (add these methods to our existing User model)
class User < ApplicationRecord
# ... existing code ...
# Swipe relationships
has_many :likes_given, class_name: 'Like', foreign_key: 'user_id'
has_many :likes_received, class_name: 'Like', foreign_key: 'target_user_id'
has_many :matches_as_user1, class_name: 'Match', foreign_key: 'user1_id'
has_many :matches_as_user2, class_name: 'Match', foreign_key: 'user2_id'
def matches
Match.where("user1_id = ? OR user2_id = ?", id, id)
end
def potential_matches
# People you haven't swiped on yet who are looking for your gender
User.where(looking_for: gender)
.where(gender: looking_for)
.where.not(id: id)
.where.not(id: likes_given.select(:target_user_id))
.where("age BETWEEN ? AND ?", age - 5, age + 5) # Age-appropriate matches
end
def liked_users
User.joins(:likes_received).where(likes: { user_id: id, liked: true })
end
def users_who_liked_me
User.joins(:likes_given).where(likes: { target_user_id: id, liked: true })
end
def mutual_matches
User.joins("INNER JOIN matches ON (matches.user1_id = users.id OR matches.user2_id = users.id)")
.where("(matches.user1_id = ? OR matches.user2_id = ?) AND users.id != ?", id, id, id)
end
end
Chapter 7: The Match Model - When Two Yeses Make a Heart
rails generate model Match user1:references user2:references
# db/migrate/[timestamp]_create_matches.rb
class CreateMatches < ActiveRecord::Migration[6.1]
def change
create_table :matches do |t|
t.references :user1, null: false, foreign_key: { to_table: :users }
t.references :user2, null: false, foreign_key: { to_table: :users }
t.boolean :active, default: true
t.datetime :matched_at
t.timestamps
t.index [:user1_id, :user2_id], unique: true
end
end
end
# app/models/match.rb
class Match < ApplicationRecord
belongs_to :user1, class_name: 'User'
belongs_to :user2, class_name: 'User'
validates :user1_id, uniqueness: { scope: :user2_id, message: "You two are already a thing! Don't be greedy." }
validate :cannot_match_with_self
before_create :set_matched_at
def other_user(current_user)
current_user == user1 ? user2 : user1
end
def self.between(user1, user2)
where("(user1_id = ? AND user2_id = ?) OR (user1_id = ? AND user2_id = ?)",
user1.id, user2.id, user2.id, user1.id).first
end
private
def cannot_match_with_self
errors.add(:user2_id, "Sorry, self-love is great but you can't match with yourself!") if user1_id == user2_id
end
def set_matched_at
self.matched_at = Time.current
end
end
Chapter 8: The Swiping Interface - Where Thumbs Go to Work
Let's build our matches controller for the swiping magic:
rails generate controller Matches index like pass
# app/controllers/matches_controller.rb
class MatchesController < ApplicationController
before_action :authenticate_user!
def index
# Get the next potential match for swiping
@potential_match = current_user.potential_matches.first
if @potential_match.nil?
flash[:info] = "You've swiped through everyone! Time to work on those conversations! ๐ฌ"
redirect_to conversations_path and return
end
# Show user's current matches in the sidebar
@matches = current_user.matches.limit(10)
end
def like
target_user = User.find(params[:id])
# Create the like
like = current_user.likes_given.create!(target_user: target_user, liked: true)
# Check if it's a match
if Like.exists?(user: target_user, target_user: current_user, liked: true)
flash[:success] = "IT'S A MATCH! ๐ You and #{target_user.name} like each other!"
else
flash[:notice] = "You liked #{target_user.name}! Fingers crossed they like you back! ๐ค"
end
redirect_to matches_path
end
def pass
target_user = User.find(params[:id])
# Create the pass (a "like" with liked: false)
current_user.likes_given.create!(target_user: target_user, liked: false)
flash[:info] = "You passed on #{target_user.name}. Their loss! ๐"
redirect_to matches_path
end
end
Now for the fun part - the swiping interface!
<%# app/views/matches/index.html.erb %>
<div class="columns">
<!-- Sidebar with current matches -->
<div class="column is-one-quarter">
<div class="box">
<h3 class="title is-4">Your Matches ๐</h3>
<% if @matches.any? %>
<div class="match-list">
<% @matches.each do |match| %>
<% other_user = match.other_user(current_user) %>
<div class="media match-item">
<div class="media-left">
<figure class="image is-32x32">
<% if other_user.profile_picture %>
<%= image_tag other_user.profile_picture, class: "is-rounded" %>
<% else %>
<img src="https://via.placeholder.com/32x32/FF69B4/FFFFFF?text=โค๏ธ" class="is-rounded">
<% end %>
</figure>
</div>
<div class="media-content">
<p class="is-size-6">
<strong><%= other_user.name %></strong>
<br>
<small>Matched <%= time_ago_in_words(match.matched_at) %> ago</small>
</p>
</div>
</div>
<% end %>
</div>
<% else %>
<p class="has-text-grey">No matches yet! Keep swiping! ๐ช</p>
<% end %>
</div>
</div>
<!-- Main swiping area -->
<div class="column">
<div class="card profile-card">
<div class="card-image">
<figure class="image is-4by3">
<% if @potential_match.profile_picture %>
<%= image_tag @potential_match.profile_picture %>
<% else %>
<img src="https://via.placeholder.com/400x300/FF69B4/FFFFFF?text=Profile+Pic" alt="Default profile">
<% end %>
</figure>
</div>
<div class="card-content">
<div class="media">
<div class="media-content">
<p class="title is-4">
<%= @potential_match.name %>, <%= @potential_match.age %>
<% if @potential_match.verified %>
<span class="tag is-success is-small">โ
</span>
<% end %>
</p>
<p class="subtitle is-6">
๐ <%= @potential_match.location %> โข ๐ <%= @potential_match.display_height %>
</p>
</div>
</div>
<div class="content">
<p><%= @potential_match.bio || "This person prefers mystery over biography." %></p>
<% if @potential_match.interests.any? %>
<div class="tags">
<% @potential_match.interests.each do |interest| %>
<span class="tag is-primary is-light"><%= interest %></span>
<% end %>
</div>
<% end %>
<br>
<small>Looking for: <strong><%= @potential_match.looking_for %></strong></small>
</div>
</div>
<footer class="card-footer">
<%= button_to "๐ Pass", pass_match_path(@potential_match),
method: :post,
class: "card-footer-item button is-danger is-light" %>
<%= button_to "โค๏ธ Like", like_match_path(@potential_match),
method: :post,
class: "card-footer-item button is-success is-light" %>
</footer>
</div>
<div class="has-text-centered mt-4">
<p class="has-text-grey">
<%= current_user.likes_given.count %> profiles swiped โข
<%= current_user.matches.count %> matches
</p>
</div>
</div>
</div>
Chapter 9: Match Notifications - The Digital "They Like You!"
Let's add some JavaScript to make the swiping more Tinder-like:
// app/javascript/packs/application.js
document.addEventListener('DOMContentLoaded', function() {
const likeButtons = document.querySelectorAll('[data-swipe="like"]');
const passButtons = document.querySelectorAll('[data-swipe="pass"]');
likeButtons.forEach(button => {
button.addEventListener('click', function() {
// Add some visual feedback
this.classList.add('is-loading');
// You could add swipe animations here later
});
});
// Simple keyboard shortcuts for power users
document.addEventListener('keydown', function(event) {
if (event.key === 'ArrowLeft') {
// Pass on left arrow
const passButton = document.querySelector('[data-swipe="pass"]');
if (passButton) passButton.click();
} else if (event.key === 'ArrowRight') {
// Like on right arrow
const likeButton = document.querySelector('[data-swipe="like"]');
if (likeButton) likeButton.click();
}
});
});
Chapter 10: The "It's a Match!" Modal
Let's create a fancy modal for when users get a match:
<%# app/views/layouts/application.html.erb - add this before closing body tag %>
<% if flash[:success] && flash[:success].include?("IT'S A MATCH") %>
<div class="modal is-active">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box has-text-centered">
<h2 class="title is-2">IT'S A MATCH! ๐</h2>
<p class="subtitle">You both liked each other! The universe approves! ๐</p>
<div class="content">
<p><%= flash[:success].gsub("IT'S A MATCH! ๐ ", "") %></p>
</div>
<button class="button is-success is-large" onclick="closeModal()">
Start Chatting! ๐ฌ
</button>
</div>
</div>
</div>
<script>
function closeModal() {
document.querySelector('.modal').classList.remove('is-active');
}
</script>
<% end %>
Chapter 11: Seeding with Realistic Swipes
Let's update our seeds to include some realistic swiping behavior:
# db/seeds.rb (add this after creating users)
puts "Creating realistic swiping patterns..."
users = User.all
users.each do |user|
# Each user swipes on 20-40 other profiles
swipe_count = rand(20..40)
potential_targets = user.potential_matches.limit(50)
potential_targets.sample(swipe_count).each do |target|
# 60% chance of like, 40% chance of pass
liked = rand < 0.6
Like.create!(
user: user,
target_user: target,
liked: liked
)
puts "#{user.name} #{liked ? 'โค๏ธ liked' : '๐ passed'} #{target.name}"
end
end
puts "Created #{Like.count} swipes and #{Match.count} matches! Love is in the air! ๐"
What We've Built in Part 2:
- โ Swipe System: Like/pass functionality with proper database relationships
- โ Matching Logic: Automatic match creation when two users like each other
- โ Swiping Interface: Tinder-like interface for browsing profiles
- โ Match Management: View current matches and match history
- โ Interactive Features: Keyboard shortcuts and match notifications
Coming Up in Part 3:
In our next romantic installment, we'll build:
- Real-time Messaging: Because "hey" deserves an immediate response
- Conversation Management: Threaded chats for each match
- Push Notifications: "You have a new message!" (the digital equivalent of butterflies)
- Read Receipts: So you know exactly when you've been left on read
Current Status: Your app now has the core dating functionality! Users can swipe, match, and feel the thrill of mutual interest. You're basically the digital Cupid now - just remember, with great power comes great responsibility (and probably some weird support emails).
Remember: In the world of dating apps, a match is just the beginning. It's like getting a phone number in a bar - now you actually have to talk to them!
Happy coding, you romantic architect! ๐
Let's Build Love (or Something Like It): A Rails Dating App Manual
Part 3: Messaging, Real-Time Chat, and Digital Butterflies
Welcome back, you digital Casanova! We've built the swiping, we've got the matches... now comes the hard part: actual conversation! Let's build a messaging system that'll make "hey" feel like poetry (or at least make ghosting more efficient).
Chapter 12: Conversations and Messages - Where "u up?" Becomes Art
First, let's generate our conversation models. Because nothing says romance like well-structured database relationships!
rails generate model Conversation user1:references user2:references last_message_at:datetime
rails generate model Message conversation:references user:references body:text read:boolean
# db/migrate/[timestamp]_create_conversations.rb
class CreateConversations < ActiveRecord::Migration[6.1]
def change
create_table :conversations do |t|
t.references :user1, null: false, foreign_key: { to_table: :users }
t.references :user2, null: false, foreign_key: { to_table: :users }
t.datetime :last_message_at
t.boolean :active, default: true
t.timestamps
t.index [:user1_id, :user2_id], unique: true
end
end
end
# db/migrate/[timestamp]_create_messages.rb
class CreateMessages < ActiveRecord::Migration[6.1]
def change
create_table :messages do |t|
t.references :conversation, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.text :body, null: false
t.boolean :read, default: false
t.timestamps
end
end
end
Run rails db:migrate and watch your app learn the art of conversation!
Now let's make these models actually talk to each other:
# app/models/conversation.rb
class Conversation < ApplicationRecord
belongs_to :user1, class_name: 'User'
belongs_to :user2, class_name: 'User'
has_many :messages, dependent: :destroy
validates :user1_id, uniqueness: { scope: :user2_id, message: "Conversation already exists! No need to start over." }
scope :for_user, ->(user) {
where("user1_id = ? OR user2_id = ?", user.id, user.id)
}
scope :with_messages, -> {
joins(:messages).distinct
}
scope :recent, -> {
order(last_message_at: :desc)
}
def other_user(current_user)
current_user == user1 ? user2 : user1
end
def participants
[user1, user2]
end
def unread_messages_count_for(user)
messages.where.not(user: user).where(read: false).count
end
def mark_as_read_for(user)
messages.where.not(user: user).update_all(read: true)
end
def update_last_message_at
update(last_message_at: messages.last&.created_at || Time.current)
end
end
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :conversation
belongs_to :user
validates :body, presence: true, length: { maximum: 1000 }
after_create :update_conversation_timestamp
after_create :broadcast_to_channel
after_create :notify_recipient
private
def update_conversation_timestamp
conversation.update_last_message_at
end
def broadcast_to_channel
# We'll implement this with Action Cable soon!
end
def notify_recipient
# We'll add push notifications later
end
end
Let's also update our User model to understand conversations:
# app/models/user.rb (add these methods)
class User < ApplicationRecord
# ... existing code ...
has_many :conversations_as_user1, class_name: 'Conversation', foreign_key: 'user1_id'
has_many :conversations_as_user2, class_name: 'Conversation', foreign_key: 'user2_id'
has_many :messages
def conversations
Conversation.for_user(self).recent
end
def conversation_with(other_user)
Conversation.between(self, other_user)
end
def unread_messages_count
conversations.sum { |convo| convo.unread_messages_count_for(self) }
end
end
And add this to the Conversation model for finding conversations between users:
# app/models/conversation.rb (add this method)
def self.between(user1, user2)
where("(user1_id = ? AND user2_id = ?) OR (user1_id = ? AND user2_id = ?)",
user1.id, user2.id, user2.id, user1.id).first
end
Chapter 13: Conversations Controller - The Digital Wingman
Let's generate our conversations controller:
rails generate controller Conversations index show create
# app/controllers/conversations_controller.rb
class ConversationsController < ApplicationController
before_action :authenticate_user!
def index
@conversations = current_user.conversations.includes(:user1, :user2, messages: :user)
@unread_count = current_user.unread_messages_count
end
def show
@conversation = Conversation.find(params[:id])
# Make sure the current user is part of this conversation
unless @conversation.participants.include?(current_user)
redirect_to conversations_path, alert: "Nice try! That's not your conversation to read! ๐"
return
end
@other_user = @conversation.other_user(current_user)
@messages = @conversation.messages.order(created_at: :asc)
# Mark messages as read when viewing
@conversation.mark_as_read_for(current_user)
@new_message = @conversation.messages.build
end
def create
other_user = User.find(params[:user_id])
# Find or create conversation
@conversation = Conversation.between(current_user, other_user) ||
Conversation.create!(user1: current_user, user2: other_user)
redirect_to conversation_path(@conversation)
end
end
Chapter 14: Messages Controller - Where Love Letters Become 1s and 0s
rails generate controller Messages create
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
before_action :authenticate_user!
before_action :set_conversation
def create
@message = @conversation.messages.new(message_params)
@message.user = current_user
if @message.save
redirect_to conversation_path(@conversation), notice: "Message sent! ๐"
else
redirect_to conversation_path(@conversation),
alert: "Message failed to send: #{@message.errors.full_messages.join(', ')}"
end
end
private
def set_conversation
@conversation = Conversation.find(params[:conversation_id])
unless @conversation.participants.include?(current_user)
redirect_to conversations_path, alert: "You can't message in this conversation! ๐ซ"
end
end
def message_params
params.require(:message).permit(:body)
end
end
Chapter 15: The Conversation Views - Where Digital Flirting Happens
First, let's create the conversations list:
<%# app/views/conversations/index.html.erb %>
<div class="container">
<div class="columns">
<div class="column is-one-third">
<div class="box">
<h1 class="title is-4">Your Conversations ๐ฌ</h1>
<% if @unread_count > 0 %>
<div class="notification is-info is-light">
You have <%= @unread_count %> unread message<%= @unread_count == 1 ? '' : 's' %>! ๐ฉ
</div>
<% end %>
<% if @conversations.any? %>
<div class="conversation-list">
<% @conversations.each do |conversation| %>
<% other_user = conversation.other_user(current_user) %>
<% unread_count = conversation.unread_messages_count_for(current_user) %>
<%= link_to conversation_path(conversation), class: "conversation-item media" do %>
<div class="media-left">
<figure class="image is-48x48">
<% if other_user.profile_picture %>
<%= image_tag other_user.profile_picture, class: "is-rounded" %>
<% else %>
<img src="https://via.placeholder.com/48x48/FF69B4/FFFFFF?text=โค๏ธ" class="is-rounded">
<% end %>
<% if unread_count > 0 %>
<span class="tag is-danger is-small unread-badge"><%= unread_count %></span>
<% end %>
</figure>
</div>
<div class="media-content">
<p class="is-size-6">
<strong><%= other_user.name %></strong>
<br>
<small class="has-text-grey">
<% last_message = conversation.messages.last %>
<% if last_message %>
<%= truncate(last_message.body, length: 30) %>
<% else %>
No messages yet
<% end %>
</small>
</p>
</div>
<div class="media-right">
<small class="has-text-grey">
<%= time_ago_in_words(conversation.last_message_at) %> ago
</small>
</div>
<% end %>
<% end %>
</div>
<% else %>
<div class="has-text-centered py-6">
<p class="has-text-grey">No conversations yet! ๐</p>
<p class="is-size-7 has-text-grey">Match with someone to start chatting! ๐</p>
</div>
<% end %>
</div>
</div>
<div class="column">
<div class="box has-text-centered">
<h2 class="title is-3">Your Messages</h2>
<p class="subtitle">Select a conversation to start chatting! ๐ฌ</p>
<div class="content">
<p>Or go find more matches to expand your dating pool! ๐ฃ</p>
<%= link_to "Find Matches", matches_path, class: "button is-primary" %>
</div>
</div>
</div>
</div>
</div>
Now for the actual chat interface:
<%# app/views/conversations/show.html.erb %>
<div class="container">
<div class="columns">
<div class="column is-one-third">
<%= render 'conversations/sidebar', conversations: @conversations %>
</div>
<div class="column">
<div class="box">
<!-- Chat Header -->
<div class="chat-header media">
<div class="media-left">
<figure class="image is-48x48">
<% if @other_user.profile_picture %>
<%= image_tag @other_user.profile_picture, class: "is-rounded" %>
<% else %>
<img src="https://via.placeholder.com/48x48/FF69B4/FFFFFF?text=โค๏ธ" class="is-rounded">
<% end %>
</figure>
</div>
<div class="media-content">
<p class="title is-5"><%= @other_user.name %></p>
<p class="subtitle is-6">
<%= @other_user.age %> โข <%= @other_user.location %>
<% if @other_user.verified %>
<span class="tag is-success is-small">โ
</span>
<% end %>
</p>
</div>
<div class="media-right">
<%= link_to "View Profile", profile_path(@other_user), class: "button is-small is-light" %>
</div>
</div>
<!-- Messages Area -->
<div class="chat-messages" id="chat-messages">
<% if @messages.any? %>
<% @messages.each do |message| %>
<div class="message <%= message.user == current_user ? 'is-primary' : 'is-light' %>">
<div class="message-body">
<%= message.body %>
<br>
<small class="has-text-grey">
<%= time_ago_in_words(message.created_at) %> ago
<% if message.user == current_user && message.read %>
โ Read
<% end %>
</small>
</div>
</div>
<% end %>
<% else %>
<div class="has-text-centered py-6">
<p class="has-text-grey">No messages yet! Start the conversation! ๐</p>
<p class="is-size-7 has-text-grey">Pro tip: "Hey" is overrated. Try something creative! โจ</p>
</div>
<% end %>
</div>
<!-- Message Form -->
<div class="chat-form">
<%= form_with model: [@conversation, @new_message], local: true do |form| %>
<div class="field has-addons">
<div class="control is-expanded">
<%= form.text_area :body, class: "textarea", rows: 2,
placeholder: "Type your message here... (Try something better than 'hey'!)" %>
</div>
<div class="control">
<%= form.button "Send ๐", class: "button is-primary", type: "submit" %>
</div>
</div>
<% end %>
</div>
</div>
</div>
</div>
</div>
<script>
// Auto-scroll to bottom of messages
document.addEventListener('DOMContentLoaded', function() {
const messagesContainer = document.getElementById('chat-messages');
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
});
</script>
Chapter 16: Action Cable - Real-Time Magic!
Let's make our chat real-time with Action Cable! First, let's set up our channel:
rails generate channel Conversations
# app/channels/conversations_channel.rb
class ConversationsChannel < ApplicationCable::Channel
def subscribed
# Users can only subscribe to their own conversations
current_user.conversations.each do |conversation|
stream_from "conversation_#{conversation.id}"
end
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
stop_all_streams
end
def receive(data)
# Handle incoming messages
conversation = Conversation.find(data["conversation_id"])
if conversation.participants.include?(current_user)
message = conversation.messages.create!(
user: current_user,
body: data["message"]
)
# Broadcast to all participants
ConversationsChannel.broadcast_to_conversation(conversation, message)
end
end
def self.broadcast_to_conversation(conversation, message)
ActionCable.server.broadcast(
"conversation_#{conversation.id}",
message: render_message(message)
)
end
private
def self.render_message(message)
ApplicationController.render(
partial: 'messages/message',
locals: { message: message, current_user: message.user }
)
end
end
Now let's update our Message model to broadcast:
# app/models/message.rb (update the broadcast_to_channel method)
def broadcast_to_channel
ConversationsChannel.broadcast_to_conversation(conversation, self)
end
Let's create the message partial:
<%# app/views/messages/_message.html.erb %>
<div class="message <%= message.user == current_user ? 'is-primary' : 'is-light' %>">
<div class="message-body">
<%= message.body %>
<br>
<small class="has-text-grey">
<%= time_ago_in_words(message.created_at) %> ago
<% if message.user == current_user && message.read %>
โ Read
<% end %>
</small>
</div>
</div>
Now let's update our JavaScript to handle real-time updates:
// app/javascript/channels/conversations_channel.js
import consumer from "./consumer"
consumer.subscriptions.create("ConversationsChannel", {
connected() {
console.log("Connected to conversations channel! ๐ฌ");
},
disconnected() {
console.log("Disconnected from conversations channel ๐");
},
received(data) {
console.log("New message received!", data);
// Add the new message to the chat
const messagesContainer = document.getElementById('chat-messages');
if (messagesContainer) {
messagesContainer.insertAdjacentHTML('beforeend', data.message);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// Play a subtle notification sound (optional)
this.playNotificationSound();
}
},
playNotificationSound() {
// Simple notification sound
const audio = new Audio('data:audio/wav;base64,UklGRigAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQQAAAAAAA==');
audio.volume = 0.3;
audio.play().catch(e => console.log("Audio play failed:", e));
}
});
Chapter 17: Message Seeds - Some Sample Flirting
Let's add some sample messages to our seeds:
# db/seeds.rb (add this after creating matches)
puts "Creating sample conversations and messages..."
matches = Match.all
matches.each do |match|
# Only create conversations for some matches (70%)
next unless rand < 0.7
conversation = Conversation.create!(
user1: match.user1,
user2: match.user2,
last_message_at: Time.current
)
# Create 2-10 sample messages
message_count = rand(2..10)
message_count.times do |i|
# Alternate between users
user = i.even? ? match.user1 : match.user2
message_body = if i == 0
# First message options
[
"Hey there! ๐",
"Hi! Nice to match with you!",
"Hello! How's your day going?",
"Hey! I noticed we both like #{user.interests.sample} ๐",
"Well hello there! This is exciting!",
"Hi! I'm terrible at first messages, but here goes nothing!",
"Hey! What's the most interesting thing about you?",
"Hello! Ready to make this the best dating app conversation ever?",
"Hi! I promise I'm more interesting than my profile suggests!",
"Hey! What's a fun fact about you?"
].sample
else
# Follow-up messages
[
"That's really interesting!",
"Tell me more about that!",
"I feel the same way!",
"What do you like to do for fun?",
"Have you been to any good restaurants lately?",
"What's your idea of a perfect weekend?",
"I'm really enjoying this conversation!",
"You seem really cool!",
"What's the best thing that happened to you this week?",
"If you could travel anywhere right now, where would you go?"
].sample
end
conversation.messages.create!(
user: user,
body: message_body,
read: i < message_count - 1 # Only last message is unread
)
puts "#{user.name}: #{message_body}"
end
puts "Created conversation between #{match.user1.name} and #{match.user2.name} with #{message_count} messages"
end
puts "Created #{Conversation.count} conversations and #{Message.count} messages! Love is chatting! ๐ฌ"
What We've Built in Part 3:
- โ Conversation System: Threaded messaging between matches
- โ Message Management: Send, receive, and track messages
- โ Real-Time Chat: Live updates with Action Cable
- โ Read Receipts: Know when your messages are seen
- โ Conversation List: Overview of all your chats
- โ Unread Message Counts: Never miss a message again
Coming Up in Part 4:
In our next exciting installment, we'll build:
- Push Notifications: "You have a new message!" alerts
- Message Search: Find that one conversation where you discussed cat memes
- Media Sharing: Because sometimes words aren't enough (send pics, GIFs, etc.)
- Typing Indicators: See when someone is crafting their response
- Message Reactions: Heart, laugh, or cry at messages
Current Status: Your app now has fully functional real-time messaging! Users can match, chat, and build connections. You've basically built a digital dating bar where nobody has to shout over the music!
Remember: In the world of dating apps, a great conversation starter is worth a thousand swipes. And now your users have the tools to start those conversations!
Happy coding, you digital romance architect! ๐๐ฌ
Pro Tip: Always test your messaging system by sending yourself romantic messages. It's not sad - it's quality assurance! ๐
Let's Build Love (or Something Like It): A Rails Dating App Manual
Part 4: Notifications, Media Sharing, and Digital Romance Enhancements
Welcome back, you messaging maestro! We've built the chat, but now let's add the bells and whistles that make modern dating apps actually enjoyable. Get ready for push notifications, media sharing, and all the digital flirtation enhancements that separate the "mehs" from the "marry mes"!
Chapter 18: Notifications - The Digital "Someone Actually Likes You!"
Let's start with a robust notification system. Because nothing says "you're popular" like a bunch of push notifications!
rails generate model Notification user:references notifiable:polymorphic read:boolean message:string
# db/migrate/[timestamp]_create_notifications.rb
class CreateNotifications < ActiveRecord::Migration[6.1]
def change
create_table :notifications do |t|
t.references :user, null: false, foreign_key: true
t.references :notifiable, polymorphic: true, null: false
t.boolean :read, default: false
t.string :message
t.string :notification_type
t.timestamps
end
add_index :notifications, [:user_id, :read]
add_index :notifications, [:notifiable_type, :notifiable_id]
end
end
Run rails db:migrate and watch your app learn to nag users lovingly!
Now let's make our notification model smart:
# app/models/notification.rb
class Notification < ApplicationRecord
belongs_to :user
belongs_to :notifiable, polymorphic: true
validates :message, presence: true
validates :notification_type, presence: true
scope :unread, -> { where(read: false) }
scope :recent, -> { order(created_at: :desc) }
scope :for_user, ->(user) { where(user: user) }
after_create :send_push_notification
after_create :broadcast_to_user
# Notification types
TYPES = {
new_message: 'new_message',
new_match: 'new_match',
new_like: 'new_like',
profile_view: 'profile_view',
super_like: 'super_like'
}
def mark_as_read!
update!(read: true)
end
def self.create_for_match(match)
# Notify both users about the new match
[match.user1, match.user2].each do |user|
other_user = match.other_user(user)
create!(
user: user,
notifiable: match,
message: "You matched with #{other_user.name}! ๐",
notification_type: TYPES[:new_match]
)
end
end
def self.create_for_message(message)
conversation = message.conversation
recipient = conversation.other_user(message.user)
create!(
user: recipient,
notifiable: message,
message: "New message from #{message.user.name}: #{message.body.truncate(30)}",
notification_type: TYPES[:new_message]
)
end
def self.create_for_like(like)
create!(
user: like.target_user,
notifiable: like,
message: "#{like.user.name} liked your profile! โค๏ธ",
notification_type: TYPES[:new_like]
)
end
private
def send_push_notification
# We'll implement actual push notifications later
puts "๐ข PUSH NOTIFICATION: #{message}"
end
def broadcast_to_user
NotificationsChannel.broadcast_to(user,
html: ApplicationController.render(partial: 'notifications/notification', locals: { notification: self }),
count: user.notifications.unread.count
)
end
end
Let's update our existing models to trigger notifications:
# app/models/like.rb (update the after_create callback)
after_create :create_notification
private
def create_notification
Notification.create_for_like(self)
end
# app/models/match.rb (add after_create callback)
after_create :create_notifications
private
def create_notifications
Notification.create_for_match(self)
end
# app/models/message.rb (update the after_create callback)
after_create :create_notification
private
def create_notification
Notification.create_for_message(self)
end
Chapter 19: Notifications Channel - Real-Time Alert Magic!
Let's create a channel for real-time notifications:
rails generate channel Notifications
# app/channels/notifications_channel.rb
class NotificationsChannel < ApplicationCable::Channel
def subscribed
if current_user
stream_for current_user
send_unread_count
else
reject
end
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def mark_as_read
notification = current_user.notifications.find(params[:id])
notification.mark_as_read!
broadcast_to(current_user,
action: 'marked_read',
id: notification.id,
count: current_user.notifications.unread.count
)
end
def mark_all_as_read
current_user.notifications.unread.update_all(read: true)
broadcast_to(current_user,
action: 'all_marked_read',
count: 0
)
end
private
def send_unread_count
broadcast_to(current_user,
action: 'initial_count',
count: current_user.notifications.unread.count
)
end
end
Chapter 20: Notifications Controller and Views
rails generate controller Notifications index update
# app/controllers/notifications_controller.rb
class NotificationsController < ApplicationController
before_action :authenticate_user!
def index
@notifications = current_user.notifications.recent
@unread_count = current_user.notifications.unread.count
end
def update
@notification = current_user.notifications.find(params[:id])
@notification.mark_as_read!
respond_to do |format|
format.html { redirect_to notifications_path, notice: "Notification marked as read! ๐" }
format.json { render json: { success: true, unread_count: current_user.notifications.unread.count } }
end
end
def mark_all_as_read
current_user.notifications.unread.update_all(read: true)
respond_to do |format|
format.html { redirect_to notifications_path, notice: "All notifications marked as read! ๐" }
format.json { render json: { success: true, unread_count: 0 } }
end
end
end
Now let's create the notifications view:
<%# app/views/notifications/index.html.erb %>
<div class="container">
<div class="columns">
<div class="column is-two-thirds">
<div class="box">
<div class="level">
<div class="level-left">
<h1 class="title is-4">Your Notifications ๐</h1>
</div>
<div class="level-right">
<% if @unread_count > 0 %>
<%= button_to "Mark All as Read", mark_all_as_read_notifications_path,
method: :post,
class: "button is-small is-info" %>
<% end %>
</div>
</div>
<% if @notifications.any? %>
<div class="notification-list">
<% @notifications.each do |notification| %>
<div class="notification <%= 'is-warning is-light' unless notification.read %> media">
<div class="media-left">
<span class="icon">
<% case notification.notification_type %>
<% when 'new_message' %>
๐ฌ
<% when 'new_match' %>
๐
<% when 'new_like' %>
โค๏ธ
<% else %>
๐
<% end %>
</span>
</div>
<div class="media-content">
<p><%= notification.message %></p>
<small class="has-text-grey">
<%= time_ago_in_words(notification.created_at) %> ago
</small>
</div>
<div class="media-right">
<% unless notification.read %>
<%= button_to "Mark Read", notification_path(notification),
method: :patch,
class: "button is-small is-light" %>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="has-text-centered py-6">
<p class="has-text-grey">No notifications yet! ๐ด</p>
<p class="is-size-7 has-text-grey">Go match and message people to get some attention! ๐</p>
</div>
<% end %>
</div>
</div>
<div class="column">
<div class="box">
<h3 class="title is-5">Notification Stats ๐</h3>
<div class="content">
<p>Total notifications: <strong><%= @notifications.count %></strong></p>
<p>Unread notifications: <strong><%= @unread_count %></strong></p>
<p>Your popularity score: <strong><%= (@notifications.count * 1.5).round(1) %>/10</strong> ๐</p>
</div>
</div>
</div>
</div>
</div>
Chapter 21: Media Sharing - Because Pictures Speak Louder Than "hey"
Let's add image uploads to our messages! We'll use Active Storage for this:
rails active_storage:install
rails db:migrate
Update our Message model to handle attachments:
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :conversation
belongs_to :user
has_one_attached :image # Add this line!
validates :body, presence: true, length: { maximum: 1000 }, unless: :has_image?
validate :validate_image
def has_image?
image.attached?
end
private
def validate_image
if image.attached?
if image.blob.byte_size > 5.megabytes
errors.add(:image, "is too big! Keep it under 5MB please! ๐ธ")
elsif !image.blob.content_type.starts_with?('image/')
errors.add(:image, "must be an image file! We're not running a file sharing service here! ๐")
end
end
end
end
Update our messages form to handle image uploads:
<%# app/views/conversations/show.html.erb (update the message form) %>
<div class="chat-form">
<%= form_with model: [@conversation, @new_message], local: true, html: { class: 'message-form' } do |form| %>
<div class="field">
<div class="control">
<%= form.text_area :body, class: "textarea", rows: 2,
placeholder: "Type your message here... or send a picture! ๐ธ",
id: "message-body" %>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<div class="file has-name">
<label class="file-label">
<%= form.file_field :image, class: "file-input", accept: "image/*" %>
<span class="file-cta">
<span class="file-icon">
๐ธ
</span>
<span class="file-label">
Add Image
</span>
</span>
<span class="file-name" id="image-filename">
No image selected
</span>
</label>
</div>
</div>
<div class="control">
<%= form.button "Send ๐", class: "button is-primary", type: "submit", id: "send-button" %>
</div>
</div>
<% end %>
</div>
<script>
// Update filename display when image is selected
document.addEventListener('DOMContentLoaded', function() {
const fileInput = document.querySelector('input[type="file"]');
const filenameDisplay = document.getElementById('image-filename');
if (fileInput && filenameDisplay) {
fileInput.addEventListener('change', function() {
if (this.files.length > 0) {
filenameDisplay.textContent = this.files[0].name;
} else {
filenameDisplay.textContent = 'No image selected';
}
});
}
});
</script>
Update the messages partial to display images:
<%# app/views/messages/_message.html.erb %>
<div class="message <%= message.user == current_user ? 'is-primary' : 'is-light' %>">
<div class="message-body">
<% if message.has_image? %>
<div class="message-image">
<%= image_tag message.image.variant(resize: "400x300"), class: "image" %>
<br>
<% if message.body.present? %>
<p><%= message.body %></p>
<br>
<% end %>
</div>
<% else %>
<p><%= message.body %></p>
<br>
<% end %>
<small class="has-text-grey">
<%= time_ago_in_words(message.created_at) %> ago
<% if message.user == current_user && message.read %>
โ Read
<% end %>
</small>
</div>
</div>
Chapter 22: Typing Indicators - The Digital "I'm Thinking About You"
Let's add typing indicators to our chat! First, let's update our channel:
# app/channels/conversations_channel.rb (add these methods)
def typing(data)
conversation = Conversation.find(data["conversation_id"])
if conversation.participants.include?(current_user)
ActionCable.server.broadcast(
"conversation_#{conversation.id}",
{
typing: true,
user_id: current_user.id,
user_name: current_user.name
}
)
end
end
def stop_typing(data)
conversation = Conversation.find(data["conversation_id"])
if conversation.participants.include?(current_user)
ActionCable.server.broadcast(
"conversation_#{conversation.id}",
{
typing: false,
user_id: current_user.id
}
)
end
end
Add typing indicator to the chat view:
<%# app/views/conversations/show.html.erb (add this after messages area) %>
<div id="typing-indicator" class="is-hidden">
<div class="message is-light">
<div class="message-body">
<em><span id="typing-user"></span> is typing...</em> โ๏ธ
</div>
</div>
</div>
Update the JavaScript to handle typing:
// app/javascript/channels/conversations_channel.js (add these methods)
typing() {
this.perform('typing', {
conversation_id: this.getConversationId()
});
},
stopTyping() {
this.perform('stop_typing', {
conversation_id: this.getConversationId()
});
},
getConversationId() {
// Extract conversation ID from the URL
const path = window.location.pathname;
const match = path.match(/\/conversations\/(\d+)/);
return match ? match[1] : null;
},
handleTypingIndicator(data) {
const indicator = document.getElementById('typing-indicator');
const typingUser = document.getElementById('typing-user');
if (data.typing && data.user_id !== this.getCurrentUserId()) {
typingUser.textContent = data.user_name;
indicator.classList.remove('is-hidden');
} else {
indicator.classList.add('is-hidden');
}
}
// Add to received method
received(data) {
if (data.typing !== undefined) {
this.handleTypingIndicator(data);
} else if (data.message) {
// Existing message handling code
const messagesContainer = document.getElementById('chat-messages');
if (messagesContainer) {
messagesContainer.insertAdjacentHTML('beforeend', data.message);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
this.playNotificationSound();
}
}
}
Add typing detection to the message input:
// Add to app/javascript/packs/application.js
document.addEventListener('DOMContentLoaded', function() {
const messageInput = document.getElementById('message-body');
const conversationsChannel = consumer.subscriptions.subscriptions[0];
let typingTimer;
const doneTypingInterval = 1000; // 1 second
if (messageInput && conversationsChannel) {
messageInput.addEventListener('input', function() {
conversationsChannel.typing();
// Clear existing timer
clearTimeout(typingTimer);
// Set new timer
typingTimer = setTimeout(function() {
conversationsChannel.stopTyping();
}, doneTypingInterval);
});
// Also stop typing when message is sent
const messageForm = document.querySelector('.message-form');
if (messageForm) {
messageForm.addEventListener('submit', function() {
conversationsChannel.stopTyping();
});
}
}
});
Chapter 23: Message Reactions - The Digital Emoji Flirtation
Let's add message reactions! First, we need a new model:
rails generate model Reaction message:references user:references emoji:string
# db/migrate/[timestamp]_create_reactions.rb
class CreateReactions < ActiveRecord::Migration[6.1]
def change
create_table :reactions do |t|
t.references :message, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.string :emoji, null: false
t.timestamps
t.index [:message_id, :user_id, :emoji], unique: true
end
end
end
# app/models/reaction.rb
class Reaction < ApplicationRecord
belongs_to :message
belongs_to :user
validates :emoji, presence: true
validates :user_id, uniqueness: { scope: [:message_id, :emoji], message: "You already reacted with this emoji! ๐" }
EMOJIS = ['โค๏ธ', '๐', '๐ฎ', '๐ข', '๐ก', '๐', '๐', '๐ฅ', '๐', '๐ค']
after_create :broadcast_reaction
after_destroy :broadcast_reaction_removal
private
def broadcast_reaction
MessageChannel.broadcast_to(message, {
action: 'reaction_added',
reaction: {
id: id,
emoji: emoji,
user_name: user.name,
message_id: message_id
}
})
end
def broadcast_reaction_removal
MessageChannel.broadcast_to(message, {
action: 'reaction_removed',
reaction: {
emoji: emoji,
user_id: user_id,
message_id: message_id
}
})
end
end
Update the Message model:
# app/models/message.rb (add this association)
has_many :reactions, dependent: :destroy
Add reactions to the message partial:
<%# app/views/messages/_message.html.erb (add after the timestamp) %>
<% if message.reactions.any? %>
<div class="message-reactions">
<% message.reactions.group_by(&:emoji).each do |emoji, reactions| %>
<span class="tag is-small is-light reaction-tag"
data-emoji="<%= emoji %>"
data-message-id="<%= message.id %>">
<%= emoji %> <%= reactions.count %>
</span>
<% end %>
</div>
<% end %>
Chapter 24: Enhanced Seeds with Media and Reactions
Let's update our seeds to include some media and reactions:
# db/seeds.rb (add this after creating messages)
puts "Adding media to messages and reactions..."
# Add images to some messages
messages_with_media = Message.all.sample(20)
messages_with_media.each do |message|
# Attach a sample image
message.image.attach(
io: File.open(Rails.root.join('app', 'assets', 'images', 'sample_profile.jpg')),
filename: 'sample_image.jpg',
content_type: 'image/jpeg'
)
puts "Added image to message from #{message.user.name}"
end
# Add some reactions
Message.all.sample(30).each do |message|
# Random user (could be either participant)
user = message.conversation.participants.sample
Reaction.create!(
message: message,
user: user,
emoji: Reaction::EMOJIS.sample
)
puts "#{user.name} reacted to message with #{message.reactions.last.emoji}"
end
puts "Added #{messages_with_media.count} media messages and #{Reaction.count} reactions! ๐จ"
What We've Built in Part 4:
- โ Notification System: Real-time alerts for matches, messages, and likes
- โ Media Sharing: Image uploads and display in messages
- โ Typing Indicators: See when someone is crafting a response
- โ Message Reactions: Emoji responses to messages
- โ Real-Time Updates: Live notifications and chat enhancements
- โ Notification Management: Mark as read and view history
Coming Up in Part 5:
In our final installment (for now!), we'll build:
- Advanced Matching Algorithms: Because love is just math, right? โค๏ธ
- User Preferences and Filters: Age, location, interests, and deal-breakers
- Profile Boosts and Super Likes: Premium features for the desperate (I mean, dedicated!)
- Admin Dashboard: Manage users, conversations, and site analytics
- Deployment and Scaling: Taking our digital Cupid live!
Current Status: Your app now has all the modern dating app features users expect! Notifications, media sharing, typing indicators, and reactions make this feel like a professional dating platform. You're basically the digital matchmaking equivalent of a fancy restaurant with mood lighting and a sommelier!
Remember: In the world of dating apps, features are great, but genuine connections are what matter. And now you've built the perfect digital environment for those connections to flourish!
Happy coding, you notification-slinging, media-sharing, reaction-loving romance engineer! ๐๐จ๐
Pro Tip: Always test your typing indicators by having profound conversations with yourself. It's not talking to yourself - it's quality assurance! ๐
Let's Build Love (or Something Like It): A Rails Dating App Manual
Part 5: Advanced Features, Admin Powers, and Launching to the World
Welcome back, you feature-complete Cupid! We've built an amazing dating app, but now it's time to add the secret sauce: smart matching, premium features, admin controls, and finally launching this digital romance machine to the world!
Chapter 25: Advanced Matching Algorithm - Love is Just Math, Right?
Let's build a smarter matching system that considers compatibility beyond just "you both swiped right."
First, let's enhance our User model with more detailed preferences:
rails generate migration AddPreferencesToUsers
# db/migrate/[timestamp]_add_preferences_to_users.rb
class AddPreferencesToUsers < ActiveRecord::Migration[6.1]
def change
add_column :users, :min_age, :integer, default: 18
add_column :users, :max_age, :integer, default: 99
add_column :users, :max_distance, :integer, default: 50
add_column :users, :latitude, :decimal, precision: 10, scale: 6
add_column :users, :longitude, :decimal, precision: 10, scale: 6
add_column :users, :dealbreakers, :string, array: true, default: []
add_column :users, :compatibility_score, :decimal, precision: 3, scale: 2
end
end
Run rails db:migrate and watch your users get pickier!
Now let's build our advanced matching service:
# app/services/compatibility_calculator.rb
class CompatibilityCalculator
def initialize(user1, user2)
@user1 = user1
@user2 = user2
end
def calculate
return 0.0 unless basic_requirements_met?
scores = {
age: calculate_age_score,
location: calculate_location_score,
interests: calculate_interests_score,
dealbreakers: calculate_dealbreakers_score
}
# Weighted average - because some things matter more than others
total_score = (
scores[:age] * 0.2 +
scores[:location] * 0.3 +
scores[:interests] * 0.4 +
scores[:dealbreakers] * 0.1
)
total_score.round(2)
end
def basic_requirements_met?
return false unless @user1.looking_for == @user2.gender
return false unless @user2.looking_for == @user1.gender
return false unless age_within_preferences?
true
end
private
def calculate_age_score
age_difference = (@user1.age - @user2.age).abs
case age_difference
when 0..2 then 1.0
when 3..5 then 0.8
when 6..10 then 0.5
else 0.2
end
end
def calculate_location_score
return 0.5 unless both_users_have_location?
distance = calculate_distance
max_distance = [@user1.max_distance, @user2.max_distance].min
if distance <= max_distance
# Closer is better!
(1.0 - (distance.to_f / max_distance.to_f * 0.5)).round(2)
else
0.1 # Too far, but not impossible
end
end
def calculate_interests_score
common_interests = (@user1.interests & @user2.interests).count
total_interests = (@user1.interests | @user2.interests).count
return 0.5 if total_interests.zero?
(common_interests.to_f / total_interests.to_f).round(2)
end
def calculate_dealbreakers_score
# Dealbreakers are... well, dealbreakers
user1_dealbreakers_hit = @user1.dealbreakers & @user2.interests
user2_dealbreakers_hit = @user2.dealbreakers & @user1.interests
if user1_dealbreakers_hit.any? || user2_dealbreakers_hit.any?
0.0 # Automatic fail
else
1.0 # No dealbreakers hit
end
end
def age_within_preferences?
@user1.age.between?(@user2.min_age, @user2.max_age) &&
@user2.age.between?(@user1.min_age, @user1.max_age)
end
def both_users_have_location?
@user1.latitude.present? && @user1.longitude.present? &&
@user2.latitude.present? && @user2.longitude.present?
end
def calculate_distance
# Haversine formula for great-circle distance
rad_per_deg = Math::PI / 180
earth_radius_km = 6371
lat1_rad = @user1.latitude * rad_per_deg
lat2_rad = @user2.latitude * rad_per_deg
dlat_rad = (@user2.latitude - @user1.latitude) * rad_per_deg
dlon_rad = (@user2.longitude - @user1.longitude) * rad_per_deg
a = Math.sin(dlat_rad / 2) ** 2 +
Math.cos(lat1_rad) * Math.cos(lat2_rad) *
Math.sin(dlon_rad / 2) ** 2
c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
(earth_radius_km * c).round(1) # Distance in kilometers
end
end
Now let's update our User model to use this fancy matching:
# app/models/user.rb (update the potential_matches method)
def potential_matches
base_scope = User.where(looking_for: gender)
.where(gender: looking_for)
.where.not(id: id)
.where.not(id: likes_given.select(:target_user_id))
.where("age BETWEEN ? AND ?", min_age, max_age)
# Calculate compatibility scores for each potential match
users_with_scores = base_scope.map do |user|
calculator = CompatibilityCalculator.new(self, user)
score = calculator.calculate
[user, score]
end
# Filter out incompatible matches and sort by score
users_with_scores.select { |_, score| score > 0.3 }
.sort_by { |_, score| -score }
.map(&:first)
end
def calculate_compatibility_with(other_user)
CompatibilityCalculator.new(self, other_user).calculate
end
Chapter 26: Premium Features - Because Love Should Cost Something
Let's add premium features! First, let's create a subscription model:
rails generate model Subscription user:references plan_type:string status:string stripe_subscription_id:string ends_at:datetime
# db/migrate/[timestamp]_create_subscriptions.rb
class CreateSubscriptions < ActiveRecord::Migration[6.1]
def change
create_table :subscriptions do |t|
t.references :user, null: false, foreign_key: true
t.string :plan_type, null: false
t.string :status, null: false, default: 'active'
t.string :stripe_subscription_id
t.datetime :ends_at
t.timestamps
t.index :stripe_subscription_id, unique: true
end
end
end
Run rails db:migrate and watch your app become a business!
Now let's define our premium features:
# app/models/subscription.rb
class Subscription < ApplicationRecord
belongs_to :user
PLANS = {
basic: 'basic',
premium: 'premium',
gold: 'gold'
}
STATUSES = {
active: 'active',
canceled: 'canceled',
expired: 'expired'
}
validates :plan_type, inclusion: { in: PLANS.values }
validates :status, inclusion: { in: STATUSES.values }
scope :active, -> { where(status: 'active').where('ends_at > ?', Time.current) }
def active?
status == 'active' && ends_at > Time.current
end
def premium?
active? && plan_type != PLANS[:basic]
end
def gold?
active? && plan_type == PLANS[:gold]
end
end
Let's add premium features to our User model:
# app/models/user.rb (add these methods)
def has_premium?
subscription&.premium?
end
def has_gold?
subscription&.gold?
end
def subscription
subscriptions.active.first
end
def can_see_who_liked_me?
has_premium?
end
def can_use_super_likes?
has_premium?
end
def super_likes_remaining
if has_gold?
10
elsif has_premium?
5
else
0
end
end
def can_boost_profile?
has_premium?
end
def can_see_read_receipts?
has_gold?
end
Let's create a premium features controller:
rails generate controller PremiumFeatures index boost_profile super_like
# app/controllers/premium_features_controller.rb
class PremiumFeaturesController < ApplicationController
before_action :authenticate_user!
before_action :check_premium, only: [:boost_profile, :super_like]
def index
@subscription = current_user.subscription
@super_likes_remaining = current_user.super_likes_remaining
end
def boost_profile
# Boost profile for 30 minutes
current_user.update!(profile_boosted_until: 30.minutes.from_now)
# Notify matches about the boost
current_user.matches.each do |match|
other_user = match.other_user(current_user)
Notification.create!(
user: other_user,
notifiable: current_user,
message: "#{current_user.name} boosted their profile! ๐ฅ",
notification_type: 'profile_boost'
)
end
redirect_to premium_features_path,
notice: "Profile boosted! You'll be seen by more people for the next 30 minutes! ๐"
end
def super_like
target_user = User.find(params[:user_id])
if current_user.super_likes_remaining > 0
# Create a super like (special type of like)
super_like = current_user.likes_given.create!(
target_user: target_user,
liked: true,
super_like: true
)
# Create special notification
Notification.create!(
user: target_user,
notifiable: super_like,
message: "#{current_user.name} super liked you! ๐ซ",
notification_type: 'super_like'
)
redirect_to matches_path,
notice: "Super like sent! You really stand out now! ๐"
else
redirect_to premium_features_path,
alert: "No super likes remaining! Upgrade your plan for more! ๐"
end
end
private
def check_premium
unless current_user.has_premium?
redirect_to premium_features_path,
alert: "This feature requires a premium subscription! ๐"
end
end
end
Chapter 27: Admin Dashboard - Playing Digital God
Let's build an admin dashboard to manage our digital kingdom:
rails generate controller Admin dashboard users conversations reports
# app/controllers/admin_controller.rb
class AdminController < ApplicationController
before_action :authenticate_user!
before_action :require_admin!
def dashboard
@users_count = User.count
@matches_count = Match.count
@messages_count = Message.count
@conversations_count = Conversation.count
@recent_users = User.order(created_at: :desc).limit(5)
@recent_matches = Match.includes(:user1, :user2).order(created_at: :desc).limit(5)
@stats = {
daily_messages: Message.where('created_at >= ?', 1.day.ago).count,
daily_matches: Match.where('created_at >= ?', 1.day.ago).count,
active_conversations: Conversation.with_messages.where('last_message_at >= ?', 1.day.ago).count,
premium_users: Subscription.active.premium.count
}
end
def users
@users = User.includes(:subscription).order(created_at: :desc).page(params[:page])
end
def conversations
@conversations = Conversation.includes(:user1, :user2, :messages)
.order(last_message_at: :desc)
.page(params[:page])
end
def reports
@report_type = params[:type] || 'daily'
case @report_type
when 'daily'
@data = generate_daily_report
when 'weekly'
@data = generate_weekly_report
when 'monthly'
@data = generate_monthly_report
end
end
private
def require_admin!
unless current_user.admin?
redirect_to root_path, alert: "Admin access required! ๐ซ"
end
end
def generate_daily_report
# Generate daily stats for the last 7 days
7.downto(0).map do |i|
date = i.days.ago.to_date
{
date: date,
new_users: User.where(created_at: date.all_day).count,
new_matches: Match.where(created_at: date.all_day).count,
messages_sent: Message.where(created_at: date.all_day).count
}
end
end
def generate_weekly_report
# Similar implementation for weekly data
# ... (you get the idea!)
end
def generate_monthly_report
# Similar implementation for monthly data
# ... (you're a pro by now!)
end
end
Add admin flag to users:
rails generate migration AddAdminToUsers admin:boolean
# db/migrate/[timestamp]_add_admin_to_users.rb
class AddAdminToUsers < ActiveRecord::Migration[6.1]
def change
add_column :users, :admin, :boolean, default: false
end
end
Now let's create the admin views:
<%# app/views/admin/dashboard.html.erb %>
<div class="container">
<div class="level">
<div class="level-left">
<h1 class="title is-3">Admin Dashboard ๐</h1>
</div>
<div class="level-right">
<div class="tabs">
<ul>
<li class="is-active"><%= link_to "Dashboard", admin_dashboard_path %></li>
<li><%= link_to "Users", admin_users_path %></li>
<li><%= link_to "Conversations", admin_conversations_path %></li>
<li><%= link_to "Reports", admin_reports_path %></li>
</ul>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="columns">
<div class="column">
<div class="card has-text-centered">
<div class="card-content">
<p class="title"><%= @users_count %></p>
<p class="subtitle">Total Users ๐ฅ</p>
</div>
</div>
</div>
<div class="column">
<div class="card has-text-centered">
<div class="card-content">
<p class="title"><%= @matches_count %></p>
<p class="subtitle">Matches Made ๐</p>
</div>
</div>
</div>
<div class="column">
<div class="card has-text-centered">
<div class="card-content">
<p class="title"><%= @messages_count %></p>
<p class="subtitle">Messages Sent ๐ฌ</p>
</div>
</div>
</div>
<div class="column">
<div class="card has-text-centered">
<div class="card-content">
<p class="title"><%= @stats[:premium_users] %></p>
<p class="subtitle">Premium Users ๐</p>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="columns">
<div class="column">
<div class="box">
<h3 class="title is-5">Recent Users ๐</h3>
<% @recent_users.each do |user| %>
<div class="media">
<div class="media-left">
<figure class="image is-32x32">
<img src="https://via.placeholder.com/32x32/FF69B4/FFFFFF?text=โค๏ธ" class="is-rounded">
</figure>
</div>
<div class="media-content">
<p>
<strong><%= user.name %></strong>
<br>
<small>Joined <%= time_ago_in_words(user.created_at) %> ago</small>
</p>
</div>
</div>
<% end %>
</div>
</div>
<div class="column">
<div class="box">
<h3 class="title is-5">Recent Matches ๐</h3>
<% @recent_matches.each do |match| %>
<div class="media">
<div class="media-content">
<p>
<strong><%= match.user1.name %></strong> ๐ <strong><%= match.user2.name %></strong>
<br>
<small>Matched <%= time_ago_in_words(match.created_at) %> ago</small>
</p>
</div>
</div>
<% end %>
</div>
</div>
</div>
<!-- Daily Stats -->
<div class="box">
<h3 class="title is-5">Today's Activity ๐</h3>
<div class="content">
<ul>
<li><strong><%= @stats[:daily_messages] %></strong> messages sent today</li>
<li><strong><%= @stats[:daily_matches] %></strong> new matches today</li>
<li><strong><%= @stats[:active_conversations] %></strong> active conversations</li>
</ul>
</div>
</div>
</div>
Chapter 28: Deployment - Setting Love Free in the Wild
Let's get this app deployed! We'll use Heroku for simplicity:
First, let's create our deployment configuration:
# config/database.yml (update production section)
production:
url: <%= ENV['DATABASE_URL'] %>
# Gemfile (add these)
gem 'pg', '~> 1.2' # For production
gem 'rails_12factor', group: :production
gem 'puma', '~> 5.0'
Create a Procfile for Heroku:
# Procfile
web: bundle exec puma -C config/puma.rb
worker: bundle exec sidekiq -c 2
release: bundle exec rails db:migrate
Create a puma configuration:
# config/puma.rb
workers Integer(ENV['WEB_CONCURRENCY'] || 2)
threads_count = Integer(ENV['RAILS_MAX_THREADS'] || 5)
threads threads_count, threads_count
preload_app!
rackup DefaultRackup
port ENV['PORT'] || 3000
environment ENV['RACK_ENV'] || 'development'
on_worker_boot do
ActiveRecord::Base.establish_connection
end
Create a deployment checklist:
# lib/tasks/deploy.rake
namespace :deploy do
desc "Check if everything is ready for deployment"
task checklist: :environment do
puts "๐ Deployment Checklist ๐"
puts "=" * 50
checks = {
"Database migrations" => -> { ActiveRecord::Migration.check_pending! },
"Environment variables" => -> {
required_vars = %w[SECRET_KEY_BASE DATABASE_URL]
missing = required_vars.select { |var| ENV[var].blank? }
raise "Missing env vars: #{missing.join(', ')}" if missing.any?
},
"ActiveStorage configuration" => -> {
raise "ActiveStorage service not configured" if Rails.configuration.active_storage.service.blank?
}
}
checks.each do |check_name, check|
print "๐ Checking #{check_name}..."
begin
check.call
puts " โ
"
rescue => e
puts " โ"
puts " Error: #{e.message}"
end
end
puts "=" * 50
puts "๐ Checklist complete! Ready for deployment!"
end
end
Chapter 29: Final Enhanced Seeds
Let's create a comprehensive seed file for production:
# db/seeds/production.rb
puts "๐ฑ Planting the seeds of love in production..."
# Create admin user
admin = User.create!(
email: 'admin@cupidarrows.com',
password: 'admin123',
name: 'Cupid Admin',
age: 30,
bio: 'The master of love and matches!',
location: 'Digital Heaven',
gender: 'non_binary',
looking_for: 'non_binary',
interests: ['matchmaking', 'helping people find love', 'digital romance'],
admin: true,
verified: true
)
puts "Created admin user: #{admin.email}"
# Create sample premium users
premium_users = 5.times.map do |i|
user = User.create!(
email: "premium#{i}@example.com",
password: 'password123',
name: Faker::Name.name,
age: rand(25..40),
bio: Faker::Lorem.paragraph,
location: Faker::Address.city,
gender: ['male', 'female'].sample,
looking_for: ['male', 'female'].sample,
interests: Faker::Hobby.phrases.sample(5),
verified: true
)
Subscription.create!(
user: user,
plan_type: ['premium', 'gold'].sample,
status: 'active',
ends_at: 1.year.from_now
)
puts "Created premium user: #{user.name}"
user
end
puts "๐ Production seeds planted! Ready to spread love! ๐"
What We've Built in Part 5:
- โ Advanced Matching Algorithm: Smart compatibility scoring based on multiple factors
- โ Premium Features: Subscription system with super likes and profile boosts
- โ Admin Dashboard: Complete management interface with analytics
- โ Deployment Ready: Heroku configuration and deployment checklist
- โ Production Seeds: Ready-to-use seed data for production
The Complete Digital Cupid Arsenal:
After these 5 epic parts, you've built a fully-featured dating app with:
Core Features:
- User profiles with verification
- Swipe-based matching system
- Real-time messaging with media sharing
- Notifications and typing indicators
- Message reactions
Advanced Features:
- Smart compatibility algorithm
- Location-based matching
- Premium subscriptions
- Profile boosts and super likes
- Admin dashboard with analytics
Technical Excellence:
- Real-time Action Cable features
- Image uploads with Active Storage
- Advanced database relationships
- Production-ready deployment
- Comprehensive testing seeds
Next Steps for World Domination:
- Add Payment Processing: Integrate Stripe for real subscriptions
- Mobile App: Build React Native companion apps
- Machine Learning: Implement AI-powered match suggestions
- Video Calls: Add in-app video dating features
- Community Features: Dating tips, success stories, events
- Moderation Tools: Keep the creeps out and the quality high
Final Pro Tip: Remember, you're not just building a dating app - you're creating opportunities for genuine human connections. With great power comes great responsibility... and probably some weird support emails about ghosting. ๐
You are now officially a digital Cupid! Go forth and spread love (and maybe make some money while you're at it)! ๐๐น๐
The End... for now! (But love stories never really end, do they?)