diff --git a/Gemfile b/Gemfile index 5a8ffc43..b354def0 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 14ec6457..84217449 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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 @@ -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) @@ -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) @@ -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) @@ -266,6 +293,8 @@ DEPENDENCIES capybara coffee-rails devise + factory_bot_rails + httparty jbuilder listen pg @@ -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 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d05ea0f5..9ebf1bad 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -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; +} \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1c07694e..17c48cfa 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,3 +1,9 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception + + private + + def page + params[:page] || 1 + end end diff --git a/app/controllers/hacker_news_recommendations_controller.rb b/app/controllers/hacker_news_recommendations_controller.rb new file mode 100644 index 00000000..f117a1f5 --- /dev/null +++ b/app/controllers/hacker_news_recommendations_controller.rb @@ -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 diff --git a/app/controllers/hacker_news_stories_controller.rb b/app/controllers/hacker_news_stories_controller.rb new file mode 100644 index 00000000..4fe1a66d --- /dev/null +++ b/app/controllers/hacker_news_stories_controller.rb @@ -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 diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb deleted file mode 100644 index ce3bf586..00000000 --- a/app/controllers/pages_controller.rb +++ /dev/null @@ -1,2 +0,0 @@ -class PagesController < ApplicationController -end diff --git a/app/models/hacker_news_client.rb b/app/models/hacker_news_client.rb new file mode 100644 index 00000000..4e4014f7 --- /dev/null +++ b/app/models/hacker_news_client.rb @@ -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 diff --git a/app/models/hacker_news_recommendation.rb b/app/models/hacker_news_recommendation.rb new file mode 100644 index 00000000..ce580d16 --- /dev/null +++ b/app/models/hacker_news_recommendation.rb @@ -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 diff --git a/app/models/hacker_news_story.rb b/app/models/hacker_news_story.rb new file mode 100644 index 00000000..12fb8479 --- /dev/null +++ b/app/models/hacker_news_story.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index b2091f9a..b0411b26 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/services/hacker_news_story_recorder.rb b/app/services/hacker_news_story_recorder.rb new file mode 100644 index 00000000..70c387d2 --- /dev/null +++ b/app/services/hacker_news_story_recorder.rb @@ -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 diff --git a/app/sidekiq/fetch_hn_top_stories_job.rb b/app/sidekiq/fetch_hn_top_stories_job.rb new file mode 100644 index 00000000..dd814bbb --- /dev/null +++ b/app/sidekiq/fetch_hn_top_stories_job.rb @@ -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 diff --git a/app/sidekiq/record_hn_item_job.rb b/app/sidekiq/record_hn_item_job.rb new file mode 100644 index 00000000..209da7d0 --- /dev/null +++ b/app/sidekiq/record_hn_item_job.rb @@ -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 diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb new file mode 100644 index 00000000..85355099 --- /dev/null +++ b/app/views/devise/sessions/new.html.erb @@ -0,0 +1,24 @@ +
| Title | +Author | +Score | +Recommended by | +Actions | +
|---|---|---|---|---|
| <%= link_to story.title, story.url, target: "_blank", rel: "noopener noreferrer", class: "story-link" %> | +<%= story.author %> | +<%= story.score %> | ++ <%= story.users.map(&:full_name).join(", ") %> + | ++ <% if current_user.recommended_stories.include?(story) %> + <% # Note: this does use the active record query syntax here to avoid an N + 1 query %> + <%= button_to "Unrecommend", hacker_news_recommendation_path(story.recommendations.find { |r| r.user_id == current_user.id }), method: :delete, class: 'recommend-button' %> + <% else %> + <%= button_to "Recommend", hacker_news_recommendations_path(hacker_news_story_id: story.id), method: :post, class: 'recommend-button' %> + <% end %> + |
<%= alert %>
+ <%- if user_signed_in? %> + <%= link_to "Sign Out", destroy_user_session_path, method: :delete %>