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 .env
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
RAILS_ENV=development
POSTGRES_HOST=db
POSTGRES_DB=topnews_development
POSTGRES_USER=topnews
POSTGRES_PASSWORD=topnews
5 changes: 5 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
RAILS_ENV=development
POSTGRES_HOST=db
POSTGRES_DB=topnews_development
POSTGRES_USER=topnews
POSTGRES_PASSWORD=topnews
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@
/yarn-error.log

.byebug_history

# Ignore JetBrains IDE config
/.idea
# Ignore postgres docker data
postgres_data
16 changes: 16 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Use a specific version of Ruby as the base image
FROM ruby:3.1.2 AS base

# Install dependencies for building Ruby gems and Node.js/Yarn
RUN apt-get update && apt-get install -y --no-install-recommends build-essential libssl-dev libffi-dev nodejs yarn && rm -rf /var/lib/apt/lists/*

# Set the working directory and create necessary directories
WORKDIR /topnews
RUN mkdir -p /topnews
# copy files to the images
COPY . /topnews
# Install Ruby gems
RUN gem install rails bundler && bundle install

RUN chmod +x /topnews/bin/docker-entrypoint.sh
ENTRYPOINT ["/topnews/bin/docker-entrypoint.sh"]
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ gem 'turbolinks'
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
gem 'uglifier'
gem 'web-console', group: :development
gem 'vcr', group: :test
gem 'webmock', group: :test
12 changes: 12 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 @@ -91,6 +92,9 @@ GEM
execjs
coffee-script-source (1.12.2)
concurrent-ruby (1.1.10)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
devise (4.8.1)
bcrypt (~> 3.0)
Expand All @@ -105,6 +109,7 @@ GEM
ffi (1.15.5)
globalid (1.0.0)
activesupport (>= 5.0)
hashdiff (1.1.1)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
jbuilder (2.11.5)
Expand Down Expand Up @@ -243,13 +248,18 @@ GEM
concurrent-ruby (~> 1.0)
uglifier (4.2.0)
execjs (>= 0.3.0, < 3)
vcr (6.2.0)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.0)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.23.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.9)
websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0)
Expand Down Expand Up @@ -279,7 +289,9 @@ DEPENDENCIES
turbolinks
tzinfo-data
uglifier
vcr
web-console
webmock

RUBY VERSION
ruby 3.1.2p20
Expand Down
3 changes: 3 additions & 0 deletions app/controllers/pages_controller.rb
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
class PagesController < ApplicationController
def home
redirect_to stories_view_url if user_signed_in?
end
end
13 changes: 13 additions & 0 deletions app/controllers/recommendations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class RecommendationsController < ApplicationController
before_action :authenticate_user!

def create
Recommendation.create(story_id: params[:story_id], user: current_user)
redirect_to(stories_view_url)
end

def destroy
Recommendation.first(story_id: params[:story_id], user: current_user).destroy
redirect_to(stories_view_url)
end
end
11 changes: 11 additions & 0 deletions app/controllers/stories_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class StoriesController < ApplicationController
before_action :authenticate_user!

def view
@stories = StoryRanking.top_stories
end

def recommendations
@stories = Recommendation.stories
end
end
30 changes: 30 additions & 0 deletions app/jobs/hackernews_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
require 'HackerNewsClient'

class HackernewsJob < ApplicationJob
queue_as :default
RUN_EVERY = 1.hour

# This job fetches the top stories from HackerNews and upserts them into the local database,
# associating each story with its rank.
# It uses HackerNewsClient to fetch data and Story/StoryRanking models for storage.
# Note: This functionality is dependent on a queue adapter backend to handle delayed job scheduling.

def perform(*args)
top_stories = HackerNewsClient.get_top_stories(20)
rank = 1

top_stories.each do |hn_story|
story = Story.upsert(
{ title: hn_story['title'], url: hn_story['url'], hn_story_id: hn_story['id'] },
unique_by: :hn_story_id
)
# ideally the rankings could be stored and updated as a sorted set (ZSET) in redis
# To keep things simple, use a database table to store the rankings
StoryRanking.upsert({ story_id: story[0]['id'], rank: rank }, unique_by: [:story_id, :rank])
rank += 1
end

# Uncomment once a queue adapter backend is defined and configured
# self.class.perform_later(wait: RUN_EVERY)
end
end
11 changes: 11 additions & 0 deletions app/models/recommendation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class Recommendation < ApplicationRecord
belongs_to :user
belongs_to :story

# Retrieves all unique stories from the recommendations table.
# This method queries the database for all recommended stories in the recommendations table
# @return [Array<Story>] An array of unique stories.
def self.stories
all.distinct(:story).collect(&:story)
end
end
5 changes: 5 additions & 0 deletions app/models/story.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Story < ApplicationRecord
has_many :recommendations
has_many :users, through: :recommendations

end
20 changes: 20 additions & 0 deletions app/models/story_ranking.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class StoryRanking < ApplicationRecord
belongs_to :story

# == Class Method: StoryRanking.top_stories
#
# Returns an array of stories ordered by their rank in descending order.
# This method retrieves all instances of StoryRanking, orders them by the 'rank' attribute from highest to lowest,
# and then collects the associated stories.
#
# === Usage
# # Example usage:
# top_stories = StoryRanking.top_stories
# top_stories.each do |story|
# puts story.title
# end
# @return [Array<Story>] An array of stories ordered by rank.
def self.top_stories
all.order(rank: :desc).collect(&:story)
end
end
4 changes: 4 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@ class User < ApplicationRecord
# :confirmable, :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable

has_many :recommendations
has_many :stories, through: :recommendations

end
9 changes: 9 additions & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@
<p class="alert">
<%= alert %>
</p>
<div>
<% if user_signed_in? %>
<%= link_to "Logout", destroy_user_session_path, method: :delete %>
|
<%= link_to "Recommended Stories", stories_recommendations_url %>
|
<%= link_to "Top Hacker News Stories", stories_view_url %>
<% end %>
</div>
<%= yield %>
</body>
</html>
5 changes: 4 additions & 1 deletion app/views/pages/home.html.erb
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
<h1>Welcome to Top News</h1>
<h1>Welcome to Top Hacker News</h1>
<%= link_to "Login", new_user_session_path %>
or
<%= link_to "Sign Up", new_user_registration_path %>
8 changes: 8 additions & 0 deletions app/views/stories/recommendations.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<h1>Recommendations</h1>

<% @stories.each do |story| %>
<div class="story"><%= link_to story.title, story.url %></div>
<div>Recommended by:
<%= story.recommendations.collect { |rec| rec.user.first_name ? rec.user.first_name : rec.user.email }.to_sentence %>
</div>
<% end %>
18 changes: 18 additions & 0 deletions app/views/stories/view.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<h1>Top Stories</h1>
<% if @stories.length == 0 %>
There are no stories here, run the `HackernewsJob.perform_now` Job to populate.
`rails runner "HackernewsJob.perform_now"`
<% end %>

<div class="topstories">
<% @stories.each do |story| %>
<div class="story">
<% if story.users.include?(current_user) %>
<%= link_to "❤️", unrecommend_url(story_id: story.id), method: :delete %>
<% else %>
<%= link_to "🤍", recommend_url(story_id: story.id), method: :post %>
<% end %>
<%= link_to story['title'], story['url'] %>
</div>
<% end %>
</div>
13 changes: 13 additions & 0 deletions bin/docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/bash

# cleanup any dangling server process ids
rm -f tmp/pids/server.pid

# Ensure database is created, seeded and new stories are pulled when server starts
pattern="rails server"

if [[ "${*}" =~ $pattern ]]; then
bundle exec rake db:prepare
fi

exec "${@}"
3 changes: 3 additions & 0 deletions config/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ default: &default
# For details on connection pooling, see Rails configuration guide
# http://guides.rubyonrails.org/configuring.html#database-pooling
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
database: <%= ENV['DATABASE_NAME'] || "topnews_development" %>
username: <%= ENV['DATABASE_USER'] || "topnews" %>
password: <%= ENV['DATABASE_PASSWORD'] || "topnews" %>

development:
<<: *default
Expand Down
1 change: 1 addition & 0 deletions config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,5 @@
# Use an evented file watcher to asynchronously detect changes in source code,
# routes, locales, etc. This feature depends on the listen gem.
config.file_watcher = ActiveSupport::EventedFileUpdateChecker
config.active_record.legacy_connection_handling = false
end
4 changes: 2 additions & 2 deletions config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
# test suite. You never need to work with it otherwise. Remember that
# your test database is "scratch space" for the test suite and is wiped
# and recreated between test runs. Don't rely on the data there!
config.cache_classes = true

config.cache_classes = false
# Do not eager load code on boot. This avoids loading your whole application
# just for the purpose of running a single test. If you are using a tool that
# preloads Rails for running tests, you may have to set it to true.
Expand Down Expand Up @@ -39,4 +38,5 @@

# Raises error for missing translations
# config.action_view.raise_on_missing_translations = true
config.active_record.legacy_connection_handling = false
end
8 changes: 8 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
Rails.application.routes.draw do

devise_for :users

get 'stories/view'
get 'stories/recommendations'

post 'recommendations/create/:story_id', to: 'recommendations#create', as: 'recommend'
delete 'recommendations/destroy/:story_id', to: 'recommendations#destroy', as: 'unrecommend'

root to: 'pages#home'
end
14 changes: 14 additions & 0 deletions db/migrate/20240813225125_create_stories.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class CreateStories < ActiveRecord::Migration[7.0]
def change
create_table :stories do |t|
# t.string :author
t.string :title
t.string :url
t.integer :hn_story_id

t.timestamps
end

add_index :stories, :hn_story_id, name: "hn_story_id_ix", unique: true
end
end
10 changes: 10 additions & 0 deletions db/migrate/20240814160301_create_story_rankings.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class CreateStoryRankings < ActiveRecord::Migration[7.0]
def change
create_table :story_rankings, id: false do |t|
t.integer :story_id
t.integer :rank
end

add_index :story_rankings, [:story_id, :rank], unique: true
end
end
12 changes: 12 additions & 0 deletions db/migrate/20240814174335_create_recommendations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class CreateRecommendations < ActiveRecord::Migration[7.0]
def change
create_table :recommendations do |t|
t.integer :story_id
t.integer :user_id

t.timestamps
end

add_index :recommendations, [:story_id, :user_id], unique: true
end
end
25 changes: 24 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,33 @@
#
# 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_08_14_174335) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"

create_table "recommendations", force: :cascade do |t|
t.integer "story_id"
t.integer "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["story_id", "user_id"], name: "index_recommendations_on_story_id_and_user_id", unique: true
end

create_table "stories", force: :cascade do |t|
t.string "title"
t.string "url"
t.integer "hn_story_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["hn_story_id"], name: "hn_story_id_ix", unique: true
end

create_table "story_rankings", id: false, force: :cascade do |t|
t.integer "story_id"
t.integer "rank"
t.index ["story_id", "rank"], name: "index_story_rankings_on_story_id_and_rank", unique: true
end

create_table "users", force: :cascade do |t|
t.string "first_name"
t.string "last_name"
Expand Down
Loading