From 51f8418e8fd6cc23de7101756077e225afa60c58 Mon Sep 17 00:00:00 2001 From: Sathyan Mathai Date: Thu, 18 Jul 2024 17:02:17 -0400 Subject: [PATCH 01/13] chore: added HackerNewsClient --- Gemfile | 2 ++ Gemfile.lock | 9 +++++++++ app/models/hacker_news_client.rb | 14 ++++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 app/models/hacker_news_client.rb diff --git a/Gemfile b/Gemfile index 5a8ffc43..35ca3a0a 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,8 @@ source 'https://rubygems.org' ruby File.read('.ruby-version').chomp +gem 'httparty' + 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..5b2748ba 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) @@ -92,6 +93,7 @@ GEM coffee-script-source (1.12.2) concurrent-ruby (1.1.10) crass (1.0.6) + csv (3.3.0) devise (4.8.1) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -105,6 +107,10 @@ GEM 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) @@ -124,6 +130,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 @@ -266,6 +274,7 @@ DEPENDENCIES capybara coffee-rails devise + httparty jbuilder listen pg 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 From 55f611198b5ab789291024175db5ee5ffda63da0 Mon Sep 17 00:00:00 2001 From: Sathyan Mathai Date: Thu, 18 Jul 2024 17:51:59 -0400 Subject: [PATCH 02/13] feat: added ability to fetch and save hn stories --- app/models/hacker_news_story.rb | 8 +++++ app/services/hacker_news_story_recorder.rb | 30 +++++++++++++++++++ ...240718210746_create_hacker_news_stories.rb | 17 +++++++++++ db/schema.rb | 15 +++++++++- 4 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 app/models/hacker_news_story.rb create mode 100644 app/services/hacker_news_story_recorder.rb create mode 100644 db/migrate/20240718210746_create_hacker_news_stories.rb diff --git a/app/models/hacker_news_story.rb b/app/models/hacker_news_story.rb new file mode 100644 index 00000000..16ee5c51 --- /dev/null +++ b/app/models/hacker_news_story.rb @@ -0,0 +1,8 @@ +class HackerNewsStory < ApplicationRecord + validates :hacker_news_id, presence: true, uniqueness: true + validates :author, presence: true + validates :score, presence: true + validates :hacker_news_timestamp, presence: true + validates :title, presence: true + validates :url, presence: true +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/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/schema.rb b/db/schema.rb index acc34f3b..082c1cfe 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,23 @@ # # 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_18_210746) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + 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.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" From e9e61e9e39775bacf085a1104c8b89dfa6582e42 Mon Sep 17 00:00:00 2001 From: Sathyan Mathai Date: Thu, 18 Jul 2024 22:32:52 -0400 Subject: [PATCH 03/13] feat: use jobs to store HN top stories --- Gemfile | 2 ++ Gemfile.lock | 15 ++++++++++++ app/sidekiq/fetch_hn_top_stories_job.rb | 12 ++++++++++ app/sidekiq/record_hn_item_job.rb | 11 +++++++++ config/schedule.rb | 24 +++++++++++++++++++ spec/sidekiq/fetch_hn_top_stories_job_spec.rb | 4 ++++ spec/sidekiq/record_hn_item_job_spec.rb | 4 ++++ 7 files changed, 72 insertions(+) create mode 100644 app/sidekiq/fetch_hn_top_stories_job.rb create mode 100644 app/sidekiq/record_hn_item_job.rb create mode 100644 config/schedule.rb create mode 100644 spec/sidekiq/fetch_hn_top_stories_job_spec.rb create mode 100644 spec/sidekiq/record_hn_item_job_spec.rb diff --git a/Gemfile b/Gemfile index 35ca3a0a..7ae68c7b 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,8 @@ source 'https://rubygems.org' ruby File.read('.ruby-version').chomp gem 'httparty' +gem 'sidekiq' +gem 'whenever', require: false gem 'byebug', platforms: [:mri, :mingw, :x64_mingw], group: [:development, :test] gem 'capybara', group: [:development, :test] diff --git a/Gemfile.lock b/Gemfile.lock index 5b2748ba..6f731ded 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,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) @@ -92,6 +93,7 @@ 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) @@ -119,6 +121,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) @@ -194,6 +197,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) @@ -232,6 +237,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) @@ -262,6 +273,8 @@ GEM websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + whenever (1.0.0) + chronic (>= 0.6.3) xpath (3.2.0) nokogiri (~> 1.8) zeitwerk (2.6.0) @@ -284,11 +297,13 @@ DEPENDENCIES rspec-rails sass-rails selenium-webdriver + sidekiq spring turbolinks tzinfo-data uglifier web-console + whenever RUBY VERSION ruby 3.1.2p20 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/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/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..3b640718 --- /dev/null +++ b/spec/sidekiq/fetch_hn_top_stories_job_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' +RSpec.describe FetchHnTopStoriesJob, type: :job do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/sidekiq/record_hn_item_job_spec.rb b/spec/sidekiq/record_hn_item_job_spec.rb new file mode 100644 index 00000000..fb03dcfa --- /dev/null +++ b/spec/sidekiq/record_hn_item_job_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' +RSpec.describe RecordHnItemJob, type: :job do + pending "add some examples to (or delete) #{__FILE__}" +end From eefc2230ff0daca4e6f418058eedaebe4fe474d2 Mon Sep 17 00:00:00 2001 From: Sathyan Mathai Date: Fri, 19 Jul 2024 13:54:59 -0400 Subject: [PATCH 04/13] feat: schema for users recommending stories --- app/models/hacker_news_recommendation.rb | 4 ++++ app/models/hacker_news_story.rb | 5 +++++ app/models/user.rb | 5 +++++ ...0719174256_create_hacker_news_recommendations.rb | 10 ++++++++++ db/schema.rb | 13 ++++++++++++- db/seeds.rb | 2 ++ 6 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 app/models/hacker_news_recommendation.rb create mode 100644 db/migrate/20240719174256_create_hacker_news_recommendations.rb diff --git a/app/models/hacker_news_recommendation.rb b/app/models/hacker_news_recommendation.rb new file mode 100644 index 00000000..92484e19 --- /dev/null +++ b/app/models/hacker_news_recommendation.rb @@ -0,0 +1,4 @@ +class HackerNewsRecommendation < ApplicationRecord + belongs_to :user + belongs_to :hacker_news_story +end diff --git a/app/models/hacker_news_story.rb b/app/models/hacker_news_story.rb index 16ee5c51..9d0f9c89 100644 --- a/app/models/hacker_news_story.rb +++ b/app/models/hacker_news_story.rb @@ -1,4 +1,9 @@ class HackerNewsStory < ApplicationRecord + has_many :hacker_news_recommendations, dependent: :destroy + has_many :users, through: :hacker_news_recommendations + + alias_attribute :recommended_by, :users + validates :hacker_news_id, presence: true, uniqueness: true validates :author, presence: true validates :score, presence: true diff --git a/app/models/user.rb b/app/models/user.rb index b2091f9a..6aefc108 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,4 +1,9 @@ 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, 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/schema.rb b/db/schema.rb index 082c1cfe..38065633 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,19 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_07_18_210746) do +ActiveRecord::Schema[7.0].define(version: 2024_07_19_174256) 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"], 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 @@ -46,4 +55,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 From 4a5ad26be652baf397ee4b302f7dfaef19cdb501 Mon Sep 17 00:00:00 2001 From: Sathyan Mathai Date: Fri, 19 Jul 2024 13:56:26 -0400 Subject: [PATCH 05/13] chore: consolidated redundant validations in HackerNewsStory --- app/models/hacker_news_story.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/models/hacker_news_story.rb b/app/models/hacker_news_story.rb index 9d0f9c89..616da7ca 100644 --- a/app/models/hacker_news_story.rb +++ b/app/models/hacker_news_story.rb @@ -5,9 +5,5 @@ class HackerNewsStory < ApplicationRecord alias_attribute :recommended_by, :users validates :hacker_news_id, presence: true, uniqueness: true - validates :author, presence: true - validates :score, presence: true - validates :hacker_news_timestamp, presence: true - validates :title, presence: true - validates :url, presence: true + validates :author, :score, :hacker_news_timestamp, :title, :url, presence: true end From 0a5d44b07e9c587fc077e79118329d05c00465ba Mon Sep 17 00:00:00 2001 From: Sathyan Mathai Date: Fri, 19 Jul 2024 19:39:16 -0400 Subject: [PATCH 06/13] feat: can query stories by number of recommendations --- app/models/hacker_news_recommendation.rb | 2 +- app/models/hacker_news_story.rb | 4 +++- ...32322_add_recommendations_count_to_hacker_news_stories.rb | 5 +++++ db/schema.rb | 3 ++- 4 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20240719232322_add_recommendations_count_to_hacker_news_stories.rb diff --git a/app/models/hacker_news_recommendation.rb b/app/models/hacker_news_recommendation.rb index 92484e19..0a92546e 100644 --- a/app/models/hacker_news_recommendation.rb +++ b/app/models/hacker_news_recommendation.rb @@ -1,4 +1,4 @@ class HackerNewsRecommendation < ApplicationRecord belongs_to :user - belongs_to :hacker_news_story + belongs_to :hacker_news_story, counter_cache: :recommendations_count end diff --git a/app/models/hacker_news_story.rb b/app/models/hacker_news_story.rb index 616da7ca..b2bcd79a 100644 --- a/app/models/hacker_news_story.rb +++ b/app/models/hacker_news_story.rb @@ -1,9 +1,11 @@ class HackerNewsStory < ApplicationRecord has_many :hacker_news_recommendations, dependent: :destroy - has_many :users, through: :hacker_news_recommendations + has_many :users, through: :hacker_news_recommendations, counter_cache: true, source: :user alias_attribute :recommended_by, :users 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/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/schema.rb b/db/schema.rb index 38065633..3f7696f2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_07_19_174256) do +ActiveRecord::Schema[7.0].define(version: 2024_07_19_232322) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -32,6 +32,7 @@ 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 From 6df14b3c1aca78ca9d7618be2ee89520967d6f32 Mon Sep 17 00:00:00 2001 From: Sathyan Mathai Date: Fri, 19 Jul 2024 19:43:33 -0400 Subject: [PATCH 07/13] chore: ensured users can only recommend a story once --- app/models/hacker_news_recommendation.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/hacker_news_recommendation.rb b/app/models/hacker_news_recommendation.rb index 0a92546e..ce580d16 100644 --- a/app/models/hacker_news_recommendation.rb +++ b/app/models/hacker_news_recommendation.rb @@ -1,4 +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 From f94492d14609dc6551de51daf95b1642217b2ac6 Mon Sep 17 00:00:00 2001 From: Sathyan Mathai Date: Fri, 19 Jul 2024 20:01:23 -0400 Subject: [PATCH 08/13] chore: added database constraint to ensure one recommendation per story per user --- ...235936_add_unique_index_to_hacker_news_recommendations.rb | 5 +++++ db/schema.rb | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20240719235936_add_unique_index_to_hacker_news_recommendations.rb 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 3f7696f2..3b2d9844 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_07_19_232322) 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" @@ -20,6 +20,7 @@ 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 From beeb5b5cfbcc6aa943511531030ce8070a1b906d Mon Sep 17 00:00:00 2001 From: Sathyan Mathai Date: Fri, 19 Jul 2024 21:37:49 -0400 Subject: [PATCH 09/13] feat: users can log in and out --- app/controllers/application_controller.rb | 6 +++++ app/controllers/pages_controller.rb | 4 ++++ app/views/devise/sessions/new.html.erb | 24 +++++++++++++++++++ .../devise/shared/_error_messages.html.erb | 15 ++++++++++++ app/views/layouts/application.html.erb | 3 +++ 5 files changed, 52 insertions(+) create mode 100644 app/views/devise/sessions/new.html.erb create mode 100644 app/views/devise/shared/_error_messages.html.erb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1c07694e..68aa9058 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 require_login + redirect_to new_user_session_path unless user_signed_in? + end end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index ce3bf586..47b0d886 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,2 +1,6 @@ class PagesController < ApplicationController + before_action :require_login, only: [:home] + def home + @stories = HackerNewsStory.by_popularity + 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) + %> +

+
    + <% resource.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+<% end %> 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 %> From a76f8cec4907c28e1ed5a54cc00289b03f4d50c8 Mon Sep 17 00:00:00 2001 From: Sathyan Mathai Date: Fri, 19 Jul 2024 22:43:50 -0400 Subject: [PATCH 10/13] feat: users can recommend and unrecommend stories --- Gemfile | 1 + Gemfile.lock | 2 ++ app/controllers/application_controller.rb | 4 +++ .../hacker_news_recommendations_controller.rb | 23 +++++++++++++ app/controllers/pages_controller.rb | 2 +- app/models/hacker_news_story.rb | 1 + app/models/user.rb | 4 +++ app/views/pages/home.html.erb | 32 +++++++++++++++++++ config/routes.rb | 2 ++ 9 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 app/controllers/hacker_news_recommendations_controller.rb diff --git a/Gemfile b/Gemfile index 7ae68c7b..48237060 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,7 @@ ruby File.read('.ruby-version').chomp gem 'httparty' gem 'sidekiq' gem 'whenever', require: false +gem 'will_paginate' gem 'byebug', platforms: [:mri, :mingw, :x64_mingw], group: [:development, :test] gem 'capybara', group: [:development, :test] diff --git a/Gemfile.lock b/Gemfile.lock index 6f731ded..26fb9237 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -275,6 +275,7 @@ GEM 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) @@ -304,6 +305,7 @@ DEPENDENCIES uglifier web-console whenever + will_paginate RUBY VERSION ruby 3.1.2p20 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 68aa9058..79ecf5c4 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -6,4 +6,8 @@ class ApplicationController < ActionController::Base def require_login redirect_to new_user_session_path unless user_signed_in? end + + 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/pages_controller.rb b/app/controllers/pages_controller.rb index 47b0d886..63975540 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,6 +1,6 @@ class PagesController < ApplicationController before_action :require_login, only: [:home] def home - @stories = HackerNewsStory.by_popularity + @stories = HackerNewsStory.includes(:users, :hacker_news_recommendations).by_popularity.paginate(page: page, per_page: 50) end end diff --git a/app/models/hacker_news_story.rb b/app/models/hacker_news_story.rb index b2bcd79a..12fb8479 100644 --- a/app/models/hacker_news_story.rb +++ b/app/models/hacker_news_story.rb @@ -3,6 +3,7 @@ class HackerNewsStory < ApplicationRecord 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 diff --git a/app/models/user.rb b/app/models/user.rb index 6aefc108..b0411b26 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -8,4 +8,8 @@ class User < ApplicationRecord # :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/views/pages/home.html.erb b/app/views/pages/home.html.erb index 8bfd8294..4ae8f80c 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -1 +1,33 @@

Welcome to Top News

+ + + + + + + + + + + + <% @stories.each do |story| %> + + + + + + + <% end %> + +
TitleAuthorScoreRecommended byActions
<%= link_to story.title, story.url, target: "_blank", rel: "noopener noreferrer" %><%= story.author %><%= story.score %> + <%= story.users.map(&:full_name).join(", ") %> + + <% if user_signed_in? %> + <% if current_user.recommended_stories.include?(story) %> + <%= button_to "Unrecommend", hacker_news_recommendation_path(story.recommendations.find_by(user_id: current_user.id)), method: :delete %> + <% else %> + <%= button_to "Recommend", hacker_news_recommendations_path(hacker_news_story_id: story.id), method: :post %> + <% end %> + <% end %> +
+<%= will_paginate @stories %> diff --git a/config/routes.rb b/config/routes.rb index c12ef082..53a06f69 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do devise_for :users root to: 'pages#home' + + resources :hacker_news_recommendations, only: [:create, :destroy] end From 25e1560deb4d2f8ae91ef46886b49de66dc75592 Mon Sep 17 00:00:00 2001 From: Sathyan Mathai Date: Sat, 20 Jul 2024 10:34:13 -0400 Subject: [PATCH 11/13] added styling --- app/assets/stylesheets/application.css | 41 ++++++++++++++++++++++++++ app/views/pages/home.html.erb | 12 ++++---- 2 files changed, 46 insertions(+), 7 deletions(-) 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/views/pages/home.html.erb b/app/views/pages/home.html.erb index 4ae8f80c..8256c2f0 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -12,19 +12,17 @@ <% @stories.each do |story| %> - <%= link_to story.title, story.url, target: "_blank", rel: "noopener noreferrer" %> + <%= 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 user_signed_in? %> - <% if current_user.recommended_stories.include?(story) %> - <%= button_to "Unrecommend", hacker_news_recommendation_path(story.recommendations.find_by(user_id: current_user.id)), method: :delete %> - <% else %> - <%= button_to "Recommend", hacker_news_recommendations_path(hacker_news_story_id: story.id), method: :post %> - <% end %> + <% if current_user.recommended_stories.include?(story) %> + <%= button_to "Unrecommend", hacker_news_recommendation_path(story.recommendations.find_by(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 %> <% end %> From 4c09c942d39b2a1a873de1e7d6d44b2dd6f6abec Mon Sep 17 00:00:00 2001 From: Sathyan Mathai Date: Sat, 20 Jul 2024 14:11:15 -0400 Subject: [PATCH 12/13] fix: created index route and moved view for stories --- app/controllers/application_controller.rb | 4 ---- ...ges_controller.rb => hacker_news_stories_controller.rb} | 7 ++++--- .../home.html.erb => hacker_news_stories/index.html.erb} | 3 ++- config/routes.rb | 3 ++- 4 files changed, 8 insertions(+), 9 deletions(-) rename app/controllers/{pages_controller.rb => hacker_news_stories_controller.rb} (56%) rename app/views/{pages/home.html.erb => hacker_news_stories/index.html.erb} (80%) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 79ecf5c4..17c48cfa 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,10 +3,6 @@ class ApplicationController < ActionController::Base private - def require_login - redirect_to new_user_session_path unless user_signed_in? - end - def page params[:page] || 1 end diff --git a/app/controllers/pages_controller.rb b/app/controllers/hacker_news_stories_controller.rb similarity index 56% rename from app/controllers/pages_controller.rb rename to app/controllers/hacker_news_stories_controller.rb index 63975540..4fe1a66d 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/hacker_news_stories_controller.rb @@ -1,6 +1,7 @@ -class PagesController < ApplicationController - before_action :require_login, only: [:home] - def home +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/views/pages/home.html.erb b/app/views/hacker_news_stories/index.html.erb similarity index 80% rename from app/views/pages/home.html.erb rename to app/views/hacker_news_stories/index.html.erb index 8256c2f0..2c6db171 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/hacker_news_stories/index.html.erb @@ -20,7 +20,8 @@ <% if current_user.recommended_stories.include?(story) %> - <%= button_to "Unrecommend", hacker_news_recommendation_path(story.recommendations.find_by(user_id: current_user.id)), method: :delete, class: 'recommend-button' %> + <% # 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 %> diff --git a/config/routes.rb b/config/routes.rb index 53a06f69..adf80d2e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +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 From 94210584250403e64f16190e05bd32088d457643 Mon Sep 17 00:00:00 2001 From: Sathyan Mathai Date: Sat, 20 Jul 2024 16:59:30 -0400 Subject: [PATCH 13/13] chore: added test for fetch_hn_top_stories_job --- Gemfile | 4 ++++ Gemfile.lock | 6 ++++++ spec/factories/hacker_news_story.rb | 10 ++++++++++ spec/rails_helper.rb | 2 ++ spec/sidekiq/fetch_hn_top_stories_job_spec.rb | 13 ++++++++++++- spec/sidekiq/record_hn_item_job_spec.rb | 4 ---- spec/support/factory_bot.rb | 3 +++ 7 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 spec/factories/hacker_news_story.rb delete mode 100644 spec/sidekiq/record_hn_item_job_spec.rb create mode 100644 spec/support/factory_bot.rb diff --git a/Gemfile b/Gemfile index 48237060..b354def0 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,10 @@ 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 26fb9237..84217449 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -106,6 +106,11 @@ 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) @@ -288,6 +293,7 @@ DEPENDENCIES capybara coffee-rails devise + factory_bot_rails httparty jbuilder listen 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 index 3b640718..b3e41f2d 100644 --- a/spec/sidekiq/fetch_hn_top_stories_job_spec.rb +++ b/spec/sidekiq/fetch_hn_top_stories_job_spec.rb @@ -1,4 +1,15 @@ require 'rails_helper' + RSpec.describe FetchHnTopStoriesJob, type: :job do - pending "add some examples to (or delete) #{__FILE__}" + 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/sidekiq/record_hn_item_job_spec.rb b/spec/sidekiq/record_hn_item_job_spec.rb deleted file mode 100644 index fb03dcfa..00000000 --- a/spec/sidekiq/record_hn_item_job_spec.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'rails_helper' -RSpec.describe RecordHnItemJob, type: :job do - pending "add some examples to (or delete) #{__FILE__}" -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