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
9 changes: 9 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ source 'https://rubygems.org'

ruby File.read('.ruby-version').chomp

gem 'httparty'
gem 'sidekiq'
gem 'whenever', require: false
gem 'will_paginate'

group :development, :test do
gem 'factory_bot_rails'
end

gem 'byebug', platforms: [:mri, :mingw, :x64_mingw], group: [:development, :test]
gem 'capybara', group: [:development, :test]
gem 'coffee-rails'
Expand Down
32 changes: 32 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ GEM
addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0)
bcrypt (3.1.18)
bigdecimal (3.1.8)
bindex (0.8.1)
builder (3.2.4)
byebug (11.1.3)
Expand All @@ -82,6 +83,7 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
childprocess (4.1.0)
chronic (0.10.2)
coderay (1.1.3)
coffee-rails (5.0.0)
coffee-script (>= 2.2.0)
Expand All @@ -91,7 +93,9 @@ GEM
execjs
coffee-script-source (1.12.2)
concurrent-ruby (1.1.10)
connection_pool (2.4.1)
crass (1.0.6)
csv (3.3.0)
devise (4.8.1)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
Expand All @@ -102,9 +106,18 @@ GEM
digest (3.1.0)
erubi (1.11.0)
execjs (2.8.1)
factory_bot (6.4.6)
activesupport (>= 5.0.0)
factory_bot_rails (6.4.3)
factory_bot (~> 6.4)
railties (>= 5.0.0)
ffi (1.15.5)
globalid (1.0.0)
activesupport (>= 5.0)
httparty (0.22.0)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
jbuilder (2.11.5)
Expand All @@ -113,6 +126,7 @@ GEM
listen (3.7.1)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.0)
loofah (2.19.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
Expand All @@ -124,6 +138,8 @@ GEM
mini_mime (1.1.2)
mini_portile2 (2.8.0)
minitest (5.16.3)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
net-imap (0.2.3)
digest
net-protocol
Expand Down Expand Up @@ -186,6 +202,8 @@ GEM
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
redis-client (0.22.2)
connection_pool
regexp_parser (2.5.0)
responders (3.0.1)
actionpack (>= 5.0)
Expand Down Expand Up @@ -224,6 +242,12 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sidekiq (7.3.0)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
spring (4.1.0)
sprockets (4.1.1)
concurrent-ruby (~> 1.0)
Expand Down Expand Up @@ -254,6 +278,9 @@ GEM
websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
whenever (1.0.0)
chronic (>= 0.6.3)
will_paginate (4.0.1)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.0)
Expand All @@ -266,6 +293,8 @@ DEPENDENCIES
capybara
coffee-rails
devise
factory_bot_rails
httparty
jbuilder
listen
pg
Expand All @@ -275,11 +304,14 @@ DEPENDENCIES
rspec-rails
sass-rails
selenium-webdriver
sidekiq
spring
turbolinks
tzinfo-data
uglifier
web-console
whenever
will_paginate

RUBY VERSION
ruby 3.1.2p20
Expand Down
41 changes: 41 additions & 0 deletions app/assets/stylesheets/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,44 @@
*= require_tree .
*= require_self
*/
table {
border-collapse: collapse;
width: 100%;
}

th,
td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}

th {
background-color: #f2f2f2;
}

.recommend-button {
padding: 5px 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
width: 100%;
}

.story-link {
color: #2c3e50;
text-decoration: none;
font-weight: bold;
transition: color 0.3s ease;
}

.story-link:hover {
color: #3498db;
text-decoration: underline;
}

.story-link:visited {
color: #8e44ad;
}
6 changes: 6 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception

private

def page
params[:page] || 1
end
end
23 changes: 23 additions & 0 deletions app/controllers/hacker_news_recommendations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
class HackerNewsRecommendationsController < ApplicationController
before_action :authenticate_user!

def create
@recommendation = current_user.hacker_news_recommendations.new(hacker_news_story_id: params[:hacker_news_story_id])

if @recommendation.save
redirect_to root_path, notice: 'Story recommended successfully.'
else
redirect_to root_path, alert: 'Unable to recommend story.'
end
end

def destroy
@recommendation = current_user.hacker_news_recommendations.find(params[:id])

if @recommendation.destroy
redirect_to root_path, notice: 'Recommendation removed successfully.'
else
redirect_to root_path, alert: 'Unable to remove recommendation.'
end
end
end
7 changes: 7 additions & 0 deletions app/controllers/hacker_news_stories_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class HackerNewsStoriesController < ApplicationController
before_action :authenticate_user!

def index
@stories = HackerNewsStory.includes(:users, :hacker_news_recommendations).by_popularity.paginate(page: page, per_page: 50)
end
end
2 changes: 0 additions & 2 deletions app/controllers/pages_controller.rb

This file was deleted.

14 changes: 14 additions & 0 deletions app/models/hacker_news_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
require 'httparty'

class HackerNewsClient
include HTTParty
base_uri 'https://hacker-news.firebaseio.com/v0'

def top_story_ids
self.class.get('/topstories.json?print=pretty')
end

def fetch_item(id)
self.class.get("/item/#{id}.json?print=pretty")
end
end
6 changes: 6 additions & 0 deletions app/models/hacker_news_recommendation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class HackerNewsRecommendation < ApplicationRecord
belongs_to :user
belongs_to :hacker_news_story, counter_cache: :recommendations_count

validates :user_id, uniqueness: { scope: :hacker_news_story_id }
end
12 changes: 12 additions & 0 deletions app/models/hacker_news_story.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class HackerNewsStory < ApplicationRecord
has_many :hacker_news_recommendations, dependent: :destroy
has_many :users, through: :hacker_news_recommendations, counter_cache: true, source: :user

alias_attribute :recommended_by, :users
alias_attribute :recommendations, :hacker_news_recommendations

validates :hacker_news_id, presence: true, uniqueness: true
validates :author, :score, :hacker_news_timestamp, :title, :url, presence: true

scope :by_popularity, -> { order(recommendations_count: :desc, score: :desc) }
end
9 changes: 9 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
class User < ApplicationRecord
has_many :hacker_news_recommendations, dependent: :destroy
has_many :hacker_news_stories, through: :hacker_news_recommendations

alias_attribute :recommended_stories, :hacker_news_stories

# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable

def full_name
"#{first_name} #{last_name}"
end
end
30 changes: 30 additions & 0 deletions app/services/hacker_news_story_recorder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
class HackerNewsStoryRecorder
include ActiveModel::Model
REQUIRED_KEYS = %w[id by score time title url]

attr_accessor :api_response

validates :api_response, presence: true
validate :api_response_is_hacker_news_story

def execute
return false if invalid?

hacker_news_story_record = HackerNewsStory.find_or_initialize_by(hacker_news_id: api_response['id'])
hacker_news_story_record.update(
hacker_news_id: api_response['id'],
author: api_response['by'],
score: api_response['score'],
hacker_news_timestamp: api_response['time'],
title: api_response['title'],
url: api_response['url']
)
end

private

def api_response_is_hacker_news_story
return errors.add(:api_response, 'is not a HTTParty::Response') unless api_response.is_a?(HTTParty::Response)
return errors.add(:api_response, 'is missing required keys') unless REQUIRED_KEYS.all? { |key| api_response.key?(key) }
end
end
12 changes: 12 additions & 0 deletions app/sidekiq/fetch_hn_top_stories_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class FetchHnTopStoriesJob
include Sidekiq::Job

def perform
client = HackerNewsClient.new
top_story_ids = client.top_story_ids
saved_story_ids = HackerNewsStory.pluck(:hacker_news_id)

ids_to_fetch = top_story_ids - saved_story_ids
ids_to_fetch.each { |id| RecordHnItemJob.perform_async(id) }
end
end
11 changes: 11 additions & 0 deletions app/sidekiq/record_hn_item_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class RecordHnItemJob
include Sidekiq::Job

def perform(id)
client = HackerNewsClient.new
item = client.fetch_item(id)

recorder = HackerNewsStoryRecorder.new(api_response: item)
recorder.execute
end
end
24 changes: 24 additions & 0 deletions app/views/devise/sessions/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<h2>Log in</h2>

<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
<div class="field">
<%= f.label :email %><br />
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>

<div class="field">
<%= f.label :password %><br />
<%= f.password_field :password, autocomplete: "current-password" %>
</div>

<% if devise_mapping.rememberable? %>
<div class="field">
<%= f.check_box :remember_me %>
<%= f.label :remember_me %>
</div>
<% end %>

<div class="actions">
<%= f.submit "Log in" %>
</div>
<% end %>
15 changes: 15 additions & 0 deletions app/views/devise/shared/_error_messages.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<% if resource.errors.any? %>
<div id="error_explanation">
<h2>
<%= I18n.t("errors.messages.not_saved",
count: resource.errors.count,
resource: resource.class.model_name.human.downcase)
%>
</h2>
<ul>
<% resource.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
Loading