Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ gem "doorkeeper"
# JSON serialization for API
gem "active_model_serializers", "~> 0.10.0"

# Background jobs with Redis
gem "sidekiq"
gem "redis"
gem "sidekiq-cron"

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]

Expand Down
22 changes: 22 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ GEM
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
database_cleaner-active_record (2.2.2)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0)
Expand Down Expand Up @@ -333,6 +336,10 @@ GEM
rdoc (6.14.2)
erb
psych (>= 4.0.0)
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.25.2)
connection_pool
regexp_parser (2.10.0)
reline (0.6.2)
io-console (~> 0.5)
Expand Down Expand Up @@ -396,6 +403,17 @@ GEM
websocket (~> 1.0)
shoulda-matchers (6.5.0)
activesupport (>= 5.2.0)
sidekiq (8.0.7)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
logger (>= 1.6.2)
rack (>= 3.1.0)
redis-client (>= 0.23.2)
sidekiq-cron (2.3.1)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
globalid (>= 1.0.1)
sidekiq (>= 6.5.0)
snaky_hash (2.0.3)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
Expand Down Expand Up @@ -449,6 +467,7 @@ GEM
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode (0.4.4.5)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
Expand Down Expand Up @@ -507,10 +526,13 @@ DEPENDENCIES
pundit
rails (~> 8.0.2)
rails-controller-testing
redis
rspec-rails (~> 6.1.0)
rubocop-rails-omakase
selenium-webdriver
shoulda-matchers
sidekiq
sidekiq-cron
solid_cable
solid_cache
solid_queue
Expand Down
36 changes: 36 additions & 0 deletions app/controllers/subscriptions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
class SubscriptionsController < ApplicationController
before_action :authenticate_user!
before_action :set_question, only: [ :create ]
before_action :set_subscription, only: [ :destroy ]

def create
subscription = @question.subscriptions.find_or_initialize_by(user: current_user)

if subscription.persisted? || subscription.save
redirect_to @question, notice: "You are subscribed to question updates"
else
redirect_to @question, alert: "Failed to subscribe to updates"
end
end

def destroy
question = @subscription.question

if @subscription.user_id == current_user.id
@subscription.destroy
redirect_to question, notice: "You have unsubscribed from question updates"
else
redirect_to question, alert: "You can only unsubscribe from your own subscriptions"
end
end

private

def set_question
@question = Question.find(params[:question_id])
end

def set_subscription
@subscription = Subscription.find(params[:id])
end
end
16 changes: 16 additions & 0 deletions app/jobs/answer_notification_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class AnswerNotificationJob < ApplicationJob
queue_as :default

def perform(answer_id)
answer = Answer.find_by(id: answer_id)
return unless answer

question = answer.question

question.subscribers
.where.not(id: answer.user_id)
.find_each(batch_size: 500) do |user|
AnswerMailer.new_answer(user, answer).deliver_now
end
end
end
12 changes: 12 additions & 0 deletions app/jobs/daily_digest_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class DailyDigestJob < ApplicationJob
queue_as :default

def perform
questions = Question.where("created_at >= ?", 24.hours.ago).order(created_at: :desc)
return if questions.empty?

User.where.not(confirmed_at: nil).find_each(batch_size: 500) do |user|
DigestMailer.daily_digest(user, questions).deliver_now
end
end
end
10 changes: 10 additions & 0 deletions app/mailers/answer_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class AnswerMailer < ApplicationMailer
default from: "noreply@stackoverflow-clone.com"

def new_answer(user, answer)
@user = user
@answer = answer
@question = answer.question
mail(to: @user.email, subject: "New answer to your question: #{@question.title}")
end
end
9 changes: 9 additions & 0 deletions app/mailers/digest_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class DigestMailer < ApplicationMailer
default from: "noreply@stackoverflow-clone.com"

def daily_digest(user, questions)
@user = user
@questions = questions
mail(to: @user.email, subject: "Daily questions digest")
end
end
5 changes: 5 additions & 0 deletions app/models/answer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class Answer < ApplicationRecord
scope :best_first, -> { order(best: :desc, created_at: :desc) }

after_create_commit -> { broadcast_append_to [ question, :answers ], target: "answers" }
after_create_commit :notify_subscribers

def preview
body.truncate(50) if body
Expand Down Expand Up @@ -48,4 +49,8 @@ def no_dangerous_content
errors.add(:body, "contains potentially dangerous code")
end
end

def notify_subscribers
AnswerNotificationJob.perform_later(id)
end
end
7 changes: 7 additions & 0 deletions app/models/question.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ class Question < ApplicationRecord
has_many :links, as: :linkable, dependent: :destroy
has_one :reward, dependent: :destroy
has_many :comments, as: :commentable, dependent: :destroy
has_many :subscriptions, dependent: :destroy
has_many :subscribers, through: :subscriptions, source: :user

accepts_nested_attributes_for :links, allow_destroy: true, reject_if: :all_blank
accepts_nested_attributes_for :reward, allow_destroy: true, reject_if: :all_blank
Expand All @@ -22,6 +24,7 @@ class Question < ApplicationRecord
scope :recent, -> { order(created_at: :desc) }

after_create_commit -> { broadcast_prepend_to :questions, target: "questions" }
after_create_commit :auto_subscribe_author

def preview
body.truncate(150) if body
Expand All @@ -47,4 +50,8 @@ def no_dangerous_content
errors.add(:body, "contains potentially dangerous code")
end
end

def auto_subscribe_author
subscriptions.find_or_create_by(user: user)
end
end
6 changes: 6 additions & 0 deletions app/models/subscription.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class Subscription < ApplicationRecord
belongs_to :user
belongs_to :question

validates :user_id, uniqueness: { scope: :question_id }
end
4 changes: 3 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@ class User < ApplicationRecord
has_many :answers, dependent: :destroy
has_many :created_rewards, class_name: "Reward", dependent: :destroy
has_many :received_rewards, class_name: "Reward", foreign_key: "recipient_id", dependent: :nullify
has_many :subscriptions, dependent: :destroy
has_many :subscribed_questions, through: :subscriptions, source: :question

validates :email, presence: true, uniqueness: true, allow_blank: false
validates :unconfirmed_email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
validates :provider, :uid, presence: true, if: :oauth_user?

# Отключаем автоматическую отправку confirmation instructions для OAuth пользователей с временным email
def send_on_create_confirmation_instructions
return if oauth_user? && email.include?("@temp.local")
return if confirmed?
super
end

Expand Down
9 changes: 9 additions & 0 deletions app/views/answer_mailer/new_answer.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<h1>New answer to your question</h1>
<p>Hello, <%= @user.name %>!</p>
<p>A new answer was posted to the question “<strong><%= @question.title %></strong>”:</p>
<blockquote>
<%= simple_format @answer.body %>
</blockquote>
<p>
Go to the question: <%= link_to 'Open', question_url(@question) %>
</p>
13 changes: 13 additions & 0 deletions app/views/digest_mailer/daily_digest.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<h1>Daily questions digest</h1>
<p>Hello, <%= @user.name %>!</p>
<p>Here is the list of questions created in the last 24 hours:</p>
<ul>
<% @questions.each do |q| %>
<li>
<strong><%= q.title %></strong>
— <%= l(q.created_at, format: :short) %>
— <%= link_to 'Open', question_url(q) %>
</li>
<% end %>
</ul>
<p>Have a great day!</p>
3 changes: 3 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ class Application < Rails::Application

# Set time zone
config.time_zone = "UTC"

# Use Sidekiq for background jobs
config.active_job.queue_adapter = :sidekiq
# config.eager_load_paths << Rails.root.join("extras")
end
end
17 changes: 17 additions & 0 deletions config/initializers/sidekiq.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
require "sidekiq"
require "sidekiq-cron"

Sidekiq.configure_server do |config|
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }

schedule_file = Rails.root.join("config", "sidekiq.yml")
if File.exist?(schedule_file)
yaml = YAML.load_file(schedule_file)
schedule = yaml["schedule"] || yaml[:schedule]
Sidekiq::Cron::Job.load_from_hash(schedule) if schedule.present?
end
end

Sidekiq.configure_client do |config|
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
end
6 changes: 6 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
Rails.application.routes.draw do
require "sidekiq/web"
use_doorkeeper
devise_for :users, controllers: {
omniauth_callbacks: "users/omniauth_callbacks"
}

mount Sidekiq::Web => "/sidekiq"

resources :user_email_confirmations, path: "users/email_confirmations", controller: "users/email_confirmations", only: [ :new, :create ] do
member do
get :confirm
Expand All @@ -19,6 +22,7 @@
resources :rewards, only: :index

resources :questions, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do
resources :subscriptions, only: [ :create ], shallow: true
resources :comments, only: [ :create, :destroy ], shallow: true
resources :votes, only: [ :destroy ], defaults: { votable: "question" } do
collection do
Expand All @@ -42,6 +46,8 @@
end
end

resources :subscriptions, only: [ :destroy ]

namespace :api do
namespace :v1 do
get "profiles/me", to: "profiles#me"
Expand Down
9 changes: 9 additions & 0 deletions config/sidekiq.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
:concurrency: 5
:queues:
- default

:schedule:
daily_digest:
cron: "0 8 * * *" # каждый день в 8 часов по времени сервера
class: "DailyDigestJob"
queue: default
12 changes: 12 additions & 0 deletions db/migrate/20250908175000_create_subscriptions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class CreateSubscriptions < ActiveRecord::Migration[8.0]
def change
create_table :subscriptions do |t|
t.references :user, null: false, foreign_key: true
t.references :question, null: false, foreign_key: true

t.timestamps
end

add_index :subscriptions, [:user_id, :question_id], unique: true
end
end
14 changes: 13 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 0 additions & 5 deletions db/seeds.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
# This file creates sample data for the Stack Overflow clone application
# Run with: rails db:seed

puts "Cleaning database..."
Vote.destroy_all if defined?(Vote)
Comment.destroy_all if defined?(Comment)
Expand All @@ -10,7 +7,6 @@
Question.destroy_all if defined?(Question)
User.destroy_all

# Create test users
puts "Creating test users..."
test_user = User.create!(
email: "test@example.com",
Expand All @@ -28,7 +24,6 @@
)
puts "Created second user: #{second_user.email} with password: password123"

# Create sample questions
puts "Creating questions..."
questions = [
{
Expand Down
Binary file added dump.rdb
Binary file not shown.
Loading