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 @@ +

Log in

+ +<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %> +
+ <%= f.label :email %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email" %> +
+ +
+ <%= f.label :password %>
+ <%= f.password_field :password, autocomplete: "current-password" %> +
+ + <% if devise_mapping.rememberable? %> +
+ <%= f.check_box :remember_me %> + <%= f.label :remember_me %> +
+ <% end %> + +
+ <%= f.submit "Log in" %> +
+<% end %> diff --git a/app/views/devise/shared/_error_messages.html.erb b/app/views/devise/shared/_error_messages.html.erb new file mode 100644 index 00000000..ba7ab887 --- /dev/null +++ b/app/views/devise/shared/_error_messages.html.erb @@ -0,0 +1,15 @@ +<% if resource.errors.any? %> +
+

+ <%= I18n.t("errors.messages.not_saved", + count: resource.errors.count, + resource: resource.class.model_name.human.downcase) + %> +

+ +
+<% end %> diff --git a/app/views/hacker_news_stories/index.html.erb b/app/views/hacker_news_stories/index.html.erb new file mode 100644 index 00000000..2c6db171 --- /dev/null +++ b/app/views/hacker_news_stories/index.html.erb @@ -0,0 +1,32 @@ +

Welcome to Top News

+ + + + + + + + + + + + <% @stories.each do |story| %> + + + + + + + <% end %> + +
TitleAuthorScoreRecommended byActions
<%= 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 %> +
+<%= will_paginate @stories %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 331a7ed0..aeac0152 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -14,6 +14,9 @@

<%= alert %>

+ <%- if user_signed_in? %> + <%= link_to "Sign Out", destroy_user_session_path, method: :delete %>
+ <% end %> <%= yield %> diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb deleted file mode 100644 index 8bfd8294..00000000 --- a/app/views/pages/home.html.erb +++ /dev/null @@ -1 +0,0 @@ -

Welcome to Top News

diff --git a/config/routes.rb b/config/routes.rb index c12ef082..adf80d2e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,7 @@ Rails.application.routes.draw do devise_for :users - root to: 'pages#home' + root to: 'hacker_news_stories#index' + + resources :hacker_news_stories, only: [:index] + resources :hacker_news_recommendations, only: [:create, :destroy] end diff --git a/config/schedule.rb b/config/schedule.rb new file mode 100644 index 00000000..e28b79d6 --- /dev/null +++ b/config/schedule.rb @@ -0,0 +1,24 @@ +# Use this file to easily define all of your cron jobs. +# +# It's helpful, but not entirely necessary to understand cron before proceeding. +# http://en.wikipedia.org/wiki/Cron + +# Example: +# +# set :output, "/path/to/my/cron_log.log" +# +# every 2.hours do +# command "/usr/bin/some_great_command" +# runner "MyModel.some_method" +# rake "some:great:rake:task" +# end +# +# every 4.days do +# runner "AnotherModel.prune_old_records" +# end + +every 1.hour do + runner "FetchHnTopStoriesJob.new.perform" +end + +# Learn more: http://github.com/javan/whenever diff --git a/db/migrate/20240718210746_create_hacker_news_stories.rb b/db/migrate/20240718210746_create_hacker_news_stories.rb new file mode 100644 index 00000000..7be2d025 --- /dev/null +++ b/db/migrate/20240718210746_create_hacker_news_stories.rb @@ -0,0 +1,17 @@ +class CreateHackerNewsStories < ActiveRecord::Migration[7.0] + def change + create_table :hacker_news_stories do |t| + t.string :author, null: false + t.integer :hacker_news_id, null: false + t.integer :score, null: false + t.integer :hacker_news_timestamp, null: false + t.string :title, null: false + t.string :url, null: false + + t.timestamps + end + + add_index :hacker_news_stories, :score + add_index :hacker_news_stories, :hacker_news_timestamp + end +end diff --git a/db/migrate/20240719174256_create_hacker_news_recommendations.rb b/db/migrate/20240719174256_create_hacker_news_recommendations.rb new file mode 100644 index 00000000..ad718d6b --- /dev/null +++ b/db/migrate/20240719174256_create_hacker_news_recommendations.rb @@ -0,0 +1,10 @@ +class CreateHackerNewsRecommendations < ActiveRecord::Migration[7.0] + def change + create_table :hacker_news_recommendations do |t| + t.references :user, null: false, foreign_key: true, index: true + t.references :hacker_news_story, null: false, foreign_key: true, index: true + + t.timestamps + end + end +end diff --git a/db/migrate/20240719232322_add_recommendations_count_to_hacker_news_stories.rb b/db/migrate/20240719232322_add_recommendations_count_to_hacker_news_stories.rb new file mode 100644 index 00000000..70e6a206 --- /dev/null +++ b/db/migrate/20240719232322_add_recommendations_count_to_hacker_news_stories.rb @@ -0,0 +1,5 @@ +class AddRecommendationsCountToHackerNewsStories < ActiveRecord::Migration[7.0] + def change + add_column :hacker_news_stories, :recommendations_count, :integer, default: 0, null: false + end +end diff --git a/db/migrate/20240719235936_add_unique_index_to_hacker_news_recommendations.rb b/db/migrate/20240719235936_add_unique_index_to_hacker_news_recommendations.rb new file mode 100644 index 00000000..f3fe14bd --- /dev/null +++ b/db/migrate/20240719235936_add_unique_index_to_hacker_news_recommendations.rb @@ -0,0 +1,5 @@ +class AddUniqueIndexToHackerNewsRecommendations < ActiveRecord::Migration[7.0] + def change + add_index :hacker_news_recommendations, [:user_id, :hacker_news_story_id], unique: true, name: 'index_one_recommendation_per_user_and_story' + end +end diff --git a/db/schema.rb b/db/schema.rb index acc34f3b..3b2d9844 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,34 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2018_02_28_212101) do +ActiveRecord::Schema[7.0].define(version: 2024_07_19_235936) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "hacker_news_recommendations", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "hacker_news_story_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["hacker_news_story_id"], name: "index_hacker_news_recommendations_on_hacker_news_story_id" + t.index ["user_id", "hacker_news_story_id"], name: "index_one_recommendation_per_user_and_story", unique: true + t.index ["user_id"], name: "index_hacker_news_recommendations_on_user_id" + end + + create_table "hacker_news_stories", force: :cascade do |t| + t.string "author", null: false + t.integer "hacker_news_id", null: false + t.integer "score", null: false + t.integer "hacker_news_timestamp", null: false + t.string "title", null: false + t.string "url", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "recommendations_count", default: 0, null: false + t.index ["hacker_news_timestamp"], name: "index_hacker_news_stories_on_hacker_news_timestamp" + t.index ["score"], name: "index_hacker_news_stories_on_score" + end + create_table "users", force: :cascade do |t| t.string "first_name" t.string "last_name" @@ -33,4 +57,6 @@ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + add_foreign_key "hacker_news_recommendations", "hacker_news_stories" + add_foreign_key "hacker_news_recommendations", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 231b90ad..d9a7dbd2 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -15,3 +15,5 @@ last_name: 'Williams', password: 'Aechugh1ie' }) + +FetchHnTopStoriesJob.new.perform diff --git a/spec/factories/hacker_news_story.rb b/spec/factories/hacker_news_story.rb new file mode 100644 index 00000000..05285b90 --- /dev/null +++ b/spec/factories/hacker_news_story.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :hacker_news_story do + author { "dev" } + sequence(:hacker_news_id) { |n| n } + score { 100 } + hacker_news_timestamp { Time.now.to_i } + sequence(:title) { |n| "Title #{n}" } + sequence(:url) { |n| "http://example.com/#{n}" } + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index bbe1ba57..5e9434dd 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -55,3 +55,5 @@ # arbitrary gems may also be filtered via: # config.filter_gems_from_backtrace("gem name") end + +Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f } diff --git a/spec/sidekiq/fetch_hn_top_stories_job_spec.rb b/spec/sidekiq/fetch_hn_top_stories_job_spec.rb new file mode 100644 index 00000000..b3e41f2d --- /dev/null +++ b/spec/sidekiq/fetch_hn_top_stories_job_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe FetchHnTopStoriesJob, type: :job do + let!(:story) { create(:hacker_news_story, hacker_news_id: 1) } + let(:expected_story_ids_to_fetch) { [2, 3] } + + before do + allow_any_instance_of(HackerNewsClient).to receive(:top_story_ids).and_return([1, 2, 3]) + end + + it 'creates a job to fetch each story that has not already been saved' do + expected_story_ids_to_fetch.each { |id| expect(RecordHnItemJob).to receive(:perform_async).with(id) } + described_class.new.perform + end +end diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb new file mode 100644 index 00000000..c7890e49 --- /dev/null +++ b/spec/support/factory_bot.rb @@ -0,0 +1,3 @@ +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods +end