diff --git a/.gitignore b/.gitignore index 82701fed..afde131f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ /yarn-error.log .byebug_history +public/ diff --git a/.ruby-version b/.ruby-version index ef538c28..be94e6f5 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.1.2 +3.2.2 diff --git a/Gemfile b/Gemfile index 5a8ffc43..045ad029 100644 --- a/Gemfile +++ b/Gemfile @@ -20,3 +20,10 @@ gem 'turbolinks' gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] gem 'uglifier' gem 'web-console', group: :development +gem "font-awesome-sass" +gem 'turbo-rails' +gem 'kaminari' +gem "sidekiq-scheduler", "~> 5.0" +gem "sidekiq", "~> 7.3" + +gem "pagy", "~> 9.0.5" diff --git a/Gemfile.lock b/Gemfile.lock index 14ec6457..b8346021 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -91,6 +91,7 @@ GEM execjs coffee-script-source (1.12.2) concurrent-ruby (1.1.10) + connection_pool (2.4.1) crass (1.0.6) devise (4.8.1) bcrypt (~> 3.0) @@ -101,8 +102,15 @@ GEM diff-lcs (1.5.0) digest (3.1.0) erubi (1.11.0) + et-orbi (1.2.11) + tzinfo execjs (2.8.1) ffi (1.15.5) + font-awesome-sass (6.5.2) + sassc (~> 2.0) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) globalid (1.0.0) activesupport (>= 5.0) i18n (1.12.0) @@ -110,9 +118,22 @@ GEM jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) 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) @@ -143,6 +164,7 @@ GEM mini_portile2 (~> 2.8.0) racc (~> 1.4) orm_adapter (0.5.0) + pagy (9.0.5) pg (1.4.3) pry (0.14.1) coderay (~> 1.1) @@ -152,6 +174,7 @@ GEM public_suffix (5.0.0) puma (5.6.5) nio4r (~> 2.0) + raabro (1.4.0) racc (1.6.0) rack (2.2.4) rack-test (2.0.2) @@ -186,6 +209,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) @@ -209,6 +234,8 @@ GEM rspec-support (~> 3.10) rspec-support (3.11.1) rubyzip (2.3.2) + rufus-scheduler (3.9.1) + fugit (~> 1.1, >= 1.1.6) sass-rails (6.0.0) sassc-rails (~> 2.1, >= 2.1.1) sassc (2.4.0) @@ -224,6 +251,16 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) + sidekiq (7.3.1) + concurrent-ruby (< 2) + connection_pool (>= 2.3.0) + logger + rack (>= 2.2.4) + redis-client (>= 0.22.2) + sidekiq-scheduler (5.0.6) + rufus-scheduler (~> 3.2) + sidekiq (>= 6, < 8) + tilt (>= 1.4.0, < 3) spring (4.1.0) sprockets (4.1.1) concurrent-ruby (~> 1.0) @@ -236,6 +273,10 @@ GEM thor (1.2.1) tilt (2.0.11) timeout (0.3.0) + turbo-rails (2.0.6) + actionpack (>= 6.0.0) + activejob (>= 6.0.0) + railties (>= 6.0.0) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) @@ -266,8 +307,11 @@ DEPENDENCIES capybara coffee-rails devise + font-awesome-sass jbuilder + kaminari listen + pagy (~> 9.0.5) pg pry-rails puma @@ -275,14 +319,17 @@ DEPENDENCIES rspec-rails sass-rails selenium-webdriver + sidekiq (~> 7.3) + sidekiq-scheduler (~> 5.0) spring + turbo-rails turbolinks tzinfo-data uglifier web-console RUBY VERSION - ruby 3.1.2p20 + ruby 3.2.2p53 BUNDLED WITH 2.3.22 diff --git a/README.md b/README.md index 500f71a1..a2b32492 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # Top News: Internal Team News Feed -In order to evaluate your stengths as a developer, we are requesting you complete a brief take-home code challenge that involves some work on the full web stack. We expect this to take 2 to 4 hours of your time. After developing your solution, please submit a Pull Request on Github and we will discuss your code on a screenshare at the next interview. - ## Introduction [Hacker News](https://news.ycombinator.com/) is a well-known technology news aggregator service and forum maintained by seed stage investment firm, Y-Combinator. Via Firebase, Y-Combinator provides a simple JSON API to retrieve story information. The API requires no authentication and is documented in a [GitHub repo](https://github.com/HackerNews/API). The two most useful API calls are: @@ -9,19 +7,26 @@ In order to evaluate your stengths as a developer, we are requesting you complet * [List of top stories](https://hacker-news.firebaseio.com/v0/topstories.json) * [Show story details](https://hacker-news.firebaseio.com/v0/item/8863.json) -Suppose you have a small team of developers who all regularly browse Hacker News for industry insights. This team would like a simple way to flag articles that could be of interest to other team members and publish that list out to the rest of the team. This UI would be deployed for internal use so it would not require a public sign up but would be pre-populated with users who will be team members. -When a team member signs in, they will see recent news stories and be able to star, flag, highlight, or otherwise mark a story as interesting. A separate list should display all the stories that were deemed interesting and the name of the person who marked it so. +## Features + +* Users can sign in and out. Must be authenticated to view stories. +* Users visits top news page and sees a list of current top Hacker News stories. +* The stories available a paginated and a backgrounf job adds new stories every 3 minutes. +* The user is able to pin a story. +* The stories pinned by the team members display in both all news and pinned news tabs. +* Each story should shows the name of the team member who flagged it. +* Each story allows the member who added it to unpin it. + + +## Installation -## Requirements +Run the following commands on your terminal to the app. + If ruby version doesn't load properly please run `rbenv local 3.2.2` or equivalent command based your ruby version manager. -* Users should sign in and out. We have created a User model for you and pre-populated it with several users. -* Users should come to a page and see a list of current top Hacker News stories. -* This list does not necessarily need to be the current live list, but it should be a recent and continuously updated list. -* The number of stories displayed is up to you. -* The user should be able to star a story. The mechanism and display is up to you: flag, star, upvote, pick, etc. The UX is your choice. -* The stories chosen by the team members should display. It can be a separate page or the same page, the choice is yours. -* Each story should show the name of the team member or members who flagged it. -* As an internal tool for a small team, performance optimization is not a requirement. -* Be prepared to discuss known performance shortcomings of your solution and potential improvements. -* UX design here is of little importance. The design can be minimal or it can have zero design at all. +- `bundle install` +- `rails db:create` +- `rails db:migrate` +- `rails db:seed` +- `rails server` +- `POLLING_INTERVAL=3m bundle exec sidekiq` diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 46b20359..2b03a2e9 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -10,6 +10,42 @@ // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details // about supported directives. // +// app/assets/javascripts/application.js //= require rails-ujs //= require turbolinks +//= require news_stories //= require_tree . + +document.addEventListener('DOMContentLoaded', function() { + console.log("loading application!!!") + + var tabElms = document.querySelectorAll('button[data-bs-toggle="tab"]') + tabElms.forEach(function(tabElm) { + new bootstrap.Tab(tabElm) + }) + + // pagination logic + document.addEventListener('click', function(event) { + if (event.target.closest('.pagination a')) { + event.preventDefault() + var link = event.target.closest('.pagination a') + var url = link.href + var tabPane = link.closest('.tab-pane') + var tabId = tabPane.id + + fetch(url) + .then(response => response.text()) + .then(html => { + var parser = new DOMParser() + var doc = parser.parseFromString(html, 'text/html') + var newContent = doc.querySelector('#' + tabId) + + if (newContent) { + tabPane.innerHTML = newContent.innerHTML + history.pushState({}, '', url) + } + }) + .catch(error => console.error('Error:', error)) + } + }) +}) \ No newline at end of file diff --git a/app/assets/javascripts/news_stories.js b/app/assets/javascripts/news_stories.js new file mode 100644 index 00000000..eecdf8b2 --- /dev/null +++ b/app/assets/javascripts/news_stories.js @@ -0,0 +1,92 @@ +function pinStory(storyId, button, event) { + console.log("Pinning or Unpinning a story") + + event.preventDefault(); + fetch(`/news_stories/${storyId}/pin`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content, + 'Accept': 'application/json' + }, + body: JSON.stringify({ id: storyId }), + + }) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.text(); + }) + .then(() => { + let icon = button.querySelector('.unpinned-thumbtack-icon'); + + if (icon){ + icon.classList.replace('unpinned-thumbtack-icon', 'pinned-thumbtack-icon'); + showFlashMessage("Story successfully pinned!", 'success'); + } else { + icon = button.querySelector('.pinned-thumbtack-icon'); + + icon.classList.replace('pinned-thumbtack-icon', 'unpinned-thumbtack-icon'); + showFlashMessage("Story successfully unpinned!", 'alert'); + } + }) + .catch(error => { + console.error('Error:', error); + showFlashMessage('An error occurred while pinning the story.', 'error'); + }); + + function showFlashMessage(message, type) { + const flashContainer = document.querySelector('.alert-float') || createFlashContainer(); + + const alertClass = getAlertClass(type); + const iconClass = getIconClass(type); + + const alertHtml = ` + + `; + + flashContainer.insertAdjacentHTML('beforeend', alertHtml); + + setTimeout(() => { + const alertElement = flashContainer.lastElementChild; + if (alertElement) { + alertElement.remove(); + } + if (flashContainer.children.length === 0) { + flashContainer.remove(); + } + }, 5000); + } + + function createFlashContainer() { + const container = document.createElement('div'); + container.className = 'alert-float'; + document.body.insertBefore(container, document.body.firstChild); + return container; + } + + function getAlertClass(type) { + switch (type) { + case 'success': return 'alert-success'; + case 'error': return 'alert-danger'; + case 'alert': return 'alert-warning'; + case 'notice': return 'alert-info'; + default: return 'alert-secondary'; + } + } + + function getIconClass(type) { + switch (type) { + case 'success': return 'fas fa-check-circle'; + case 'error': return 'fas fa-exclamation-circle'; + case 'alert': return 'fas fa-exclamation-triangle'; + case 'notice': return 'fas fa-info-circle'; + default: return 'fas fa-bell'; + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css deleted file mode 100644 index d05ea0f5..00000000 --- a/app/assets/stylesheets/application.css +++ /dev/null @@ -1,15 +0,0 @@ -/* - * This is a manifest file that'll be compiled into application.css, which will include all the files - * listed below. - * - * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's - * vendor/assets/stylesheets directory can be referenced here using a relative path. - * - * You're free to add application-wide styles to this file and they'll appear at the bottom of the - * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS - * files in this directory. Styles in this file should be added after the last require_* statement. - * It is generally better to create a new file per style scope. - * - *= require_tree . - *= require_self - */ diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss new file mode 100644 index 00000000..34da50e0 --- /dev/null +++ b/app/assets/stylesheets/application.css.scss @@ -0,0 +1,99 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's + * vendor/assets/stylesheets directory can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the bottom of the + * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS + * files in this directory. Styles in this file should be added after the last require_* statement. + * It is generally better to create a new file per style scope. + * + *= require_tree . + *= require_self + */ +@import "font-awesome"; +html, body { + height: 100%; + margin: 0; + padding: 0; +} + +body { + overflow-y: scroll; +} + +#news-stories-container { + min-height: 100%; +} +.content-wrapper { + display: flex; + flex-direction: column; + height: 100vh; + padding-bottom: 50px; +} +.tab-content { + flex: 1; + overflow-y: auto; +} +.tab-pane { + height: 100%; +} +.unpinned-thumbtack-icon { + color: lightgrey; +} +.pinned-thumbtack-icon { + color: #ed702d; +} +.alert-float { + position: fixed; + top: 20px; + right: 20px; + max-width: 350px; + z-index: 9999; +} +.alert { + border: none; + border-radius: 4px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} +.alert-success { + background-color: #d4edda; + color: #155724; +} +.alert-danger { + background-color: #f8d7da; + color: #721c24; +} +.alert-warning { + background-color: #fff3cd; + color: #856404; +} +.alert-info { + background-color: #d1ecf1; + color: #0c5460; +} +.alert-icon { + margin-right: 10px; + font-size: 1.2em; +} +.stories-list { + list-style-type: none; + + li { + position: relative; + padding-left: 2.5em; + + &::before { + content: attr(value) ". "; + position: absolute; + left: 0; + width: 2em; + text-align: right; + } + } +} +.disabled_link { + pointer-events: none; +} \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1c07694e..59f265c6 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,3 +1,15 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception + include ActionView::Helpers::TagHelper + include ActionView::Context + include Pagy::Backend + + def render_flash + turbo_stream.update "flash", partial: "layouts/flash" + end + + def create_flash(type, message) + flash.now[type] = message + render_flash + end end diff --git a/app/controllers/news_stories_controller.rb b/app/controllers/news_stories_controller.rb new file mode 100644 index 00000000..9865ae65 --- /dev/null +++ b/app/controllers/news_stories_controller.rb @@ -0,0 +1,42 @@ +class NewsStoriesController < ApplicationController + before_action :authenticate_user! + + def index + @pagy_ns, @news_stories = pagy(NewsStory.order(time: :desc)) + @pagy_ps, @pinned_news_stories = pagy(NewsStory.order(time: :desc).pinned, page_param: :page_pinned) + + respond_to do |format| + format.html + end + rescue Pagy::OverflowError + redirect_to news_stories_path + end + + def pin + news_story = NewsStory.find_by(id: pin_params[:id]) + + if current_user.news_stories.include?(news_story) + current_user.news_stories.delete(news_story) + @pinned = false + else + current_user.news_stories << news_story + @pinned = true + end + + respond_to do |format| + format.json { render json: { pinned: @pinned }, status: :ok } + end + rescue ActiveRecord::RecordInvalid => e + render json: { error: e.message }, status: :unprocessable_entity + end + + private + + def pin_params + params.permit(:id) + end + + def news_stories_params + params.permit(:page, :page_pinned) + end +end \ No newline at end of file diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be794..91d19c7d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,3 @@ module ApplicationHelper -end + include Pagy::Frontend +end \ No newline at end of file diff --git a/app/helpers/news_story_helper.rb b/app/helpers/news_story_helper.rb new file mode 100644 index 00000000..1cbdc30e --- /dev/null +++ b/app/helpers/news_story_helper.rb @@ -0,0 +1,21 @@ +module NewsStoryHelper + def thumbtack_class(news_story) + news_story.pinned_by_id.nil? ? 'unpinned-thumbtack-icon' : 'pinned-thumbtack-icon' + end + + def format_time(time) + (time_ago_in_words(time) + ' ago!').gsub('about', '') + end + + def disable_unpinning(news_story) + return '' if news_story.pinned_by_id.nil? + + pinned_by_current_user(news_story) ? '' : 'disabled_link' + end + + private + + def pinned_by_current_user(news_story) + news_story.pinned_by?(current_user) + end +end \ No newline at end of file diff --git a/app/jobs/fetch_news_stories_job.rb b/app/jobs/fetch_news_stories_job.rb new file mode 100644 index 00000000..3715a145 --- /dev/null +++ b/app/jobs/fetch_news_stories_job.rb @@ -0,0 +1,74 @@ +class FetchNewsStoriesJob < ApplicationJob + queue_as :default + + HACKER_NEWS_URL = 'https://hacker-news.firebaseio.com/v0/'.freeze + + def perform + puts "Performing FetchNewsStoriesJob" + response = fetch_news_stories + + if response.any? + puts "FetchNewsStoriesJob::Perform Processing fetched data in" + process_data(response) + else + puts "FetchNewsStoriesJob::Perform Nothing to process" + end + rescue Exception => error + Rails.logger.error "Failed to fetch data: #{error}" + end + + + private + + def fetch_news_story_ids + puts "Fetching news_story_ids" + + news_story_ids_uri = URI("#{HACKER_NEWS_URL}newstories.json") + news_story_ids_response = Net::HTTP.get(news_story_ids_uri) + @fetchedNewsStories = JSON.parse(news_story_ids_response) + + puts "FetchNewsStoriesJob::FetchNewsStoryIDs got =======> #{@fetchedNewsStories}" + + filter_stories_ids_to_fetch + end + + def fetch_news_stories + fetch_news_story_ids.map do |news_story_id| + news_story_id_uri = URI("#{HACKER_NEWS_URL}item/#{news_story_id}.json") + news_story_response = Net::HTTP.get(news_story_id_uri) + + puts "FetchNewsStoriesJob::FetchNewsStories fetched =====> #{news_story_id}" + + JSON.parse(news_story_response) + end + .sort_by { |news_story| news_story['time'] } + .reverse + end + + def process_data(data) + data.each do |news_story| + puts "FetchNewsStoriesJob::ProcessData processed ====> #{news_story["id"]}" + + ns = NewsStory.create( + id: news_story["id"], + title: news_story["title"], + story_type: news_story["type"], + url: news_story["url"], + score: news_story["score"], + descendants: news_story["descendants"], + by: news_story["by"], + time: news_story["time"] + ) + + puts ns.persisted?, "FetchNewsStoriesJob::ProcessData saved ====> #{news_story["id"]}" + rescue Exception => error + Rails.logger.error "Failed to save story: #{error}" + end + end + + def filter_stories_ids_to_fetch + existing_news_stories = NewsStory.pluck(:id) + # filtering fetched news_story_ids ex. [1,2,3,4] - [3,4] => [1,2] + @fetchedNewsStories - existing_news_stories + end +end diff --git a/app/models/news_story.rb b/app/models/news_story.rb new file mode 100644 index 00000000..797445fa --- /dev/null +++ b/app/models/news_story.rb @@ -0,0 +1,17 @@ +class NewsStory < ApplicationRecord + STORIES_PER_PAGE = 25 + + belongs_to :user, optional: true, foreign_key: :pinned_by_id + + scope :pinned, -> { where.not(pinned_by_id: nil) } + + def pinned_by?(active_user) + user === active_user + end + + def fullname_of_pinner + return if user.nil? + + user.first_name + " " + user.last_name + end +end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index b2091f9a..058c26f9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,4 +3,6 @@ class User < ApplicationRecord # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :trackable, :validatable + + has_many :news_stories, class_name: 'NewsStory', foreign_key: 'pinned_by_id' end diff --git a/app/services/job_scheduler.rb b/app/services/job_scheduler.rb new file mode 100644 index 00000000..82967ce5 --- /dev/null +++ b/app/services/job_scheduler.rb @@ -0,0 +1,27 @@ +class JobScheduler + def self.schedule_fetch_job(interval_minutes) + job_name = 'fetch_news_stories' + + Sidekiq.set_schedule( + job_name, + { + 'every' => ["#{interval_minutes}m", first_in: '0s'], + 'class' => 'FetchNewsStoriesJob' + } + ) + + Sidekiq::Scheduler.instance.reload_schedule! + + Rails.logger.info "Scheduled FetchExternalDataJob to run every #{interval_minutes} minutes" + end + + def self.remove_fetch_job + job_name = 'fetch_news_stories' + + Sidekiq.remove_schedule(job_name) + + Sidekiq::Scheduler.instance.reload_schedule! + + Rails.logger.info "Removed FetchNewsStoriesJob from schedule" + end +end \ No newline at end of file diff --git a/app/views/devise/confirmations/new.html.erb b/app/views/devise/confirmations/new.html.erb new file mode 100644 index 00000000..b12dd0cb --- /dev/null +++ b/app/views/devise/confirmations/new.html.erb @@ -0,0 +1,16 @@ +

Resend confirmation instructions

+ +<%= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+ <%= f.label :email %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email", value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %> +
+ +
+ <%= f.submit "Resend confirmation instructions" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/mailer/confirmation_instructions.html.erb b/app/views/devise/mailer/confirmation_instructions.html.erb new file mode 100644 index 00000000..dc55f64f --- /dev/null +++ b/app/views/devise/mailer/confirmation_instructions.html.erb @@ -0,0 +1,5 @@ +

Welcome <%= @email %>!

+ +

You can confirm your account email through the link below:

+ +

<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>

diff --git a/app/views/devise/mailer/email_changed.html.erb b/app/views/devise/mailer/email_changed.html.erb new file mode 100644 index 00000000..32f4ba80 --- /dev/null +++ b/app/views/devise/mailer/email_changed.html.erb @@ -0,0 +1,7 @@ +

Hello <%= @email %>!

+ +<% if @resource.try(:unconfirmed_email?) %> +

We're contacting you to notify you that your email is being changed to <%= @resource.unconfirmed_email %>.

+<% else %> +

We're contacting you to notify you that your email has been changed to <%= @resource.email %>.

+<% end %> diff --git a/app/views/devise/mailer/password_change.html.erb b/app/views/devise/mailer/password_change.html.erb new file mode 100644 index 00000000..b41daf47 --- /dev/null +++ b/app/views/devise/mailer/password_change.html.erb @@ -0,0 +1,3 @@ +

Hello <%= @resource.email %>!

+ +

We're contacting you to notify you that your password has been changed.

diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb new file mode 100644 index 00000000..f667dc12 --- /dev/null +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -0,0 +1,8 @@ +

Hello <%= @resource.email %>!

+ +

Someone has requested a link to change your password. You can do this through the link below.

+ +

<%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>

+ +

If you didn't request this, please ignore this email.

+

Your password won't change until you access the link above and create a new one.

diff --git a/app/views/devise/mailer/unlock_instructions.html.erb b/app/views/devise/mailer/unlock_instructions.html.erb new file mode 100644 index 00000000..41e148bf --- /dev/null +++ b/app/views/devise/mailer/unlock_instructions.html.erb @@ -0,0 +1,7 @@ +

Hello <%= @resource.email %>!

+ +

Your account has been locked due to an excessive number of unsuccessful sign in attempts.

+ +

Click the link below to unlock your account:

+ +

<%= link_to 'Unlock my account', unlock_url(@resource, unlock_token: @token) %>

diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb new file mode 100644 index 00000000..5fbb9ff0 --- /dev/null +++ b/app/views/devise/passwords/edit.html.erb @@ -0,0 +1,25 @@ +

Change your password

+ +<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + <%= f.hidden_field :reset_password_token %> + +
+ <%= f.label :password, "New password" %>
+ <% if @minimum_password_length %> + (<%= @minimum_password_length %> characters minimum)
+ <% end %> + <%= f.password_field :password, autofocus: true, autocomplete: "new-password" %> +
+ +
+ <%= f.label :password_confirmation, "Confirm new password" %>
+ <%= f.password_field :password_confirmation, autocomplete: "new-password" %> +
+ +
+ <%= f.submit "Change my password" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb new file mode 100644 index 00000000..9b486b81 --- /dev/null +++ b/app/views/devise/passwords/new.html.erb @@ -0,0 +1,16 @@ +

Forgot your password?

+ +<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+ <%= f.label :email %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email" %> +
+ +
+ <%= f.submit "Send me reset password instructions" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb new file mode 100644 index 00000000..38d95b85 --- /dev/null +++ b/app/views/devise/registrations/edit.html.erb @@ -0,0 +1,43 @@ +

Edit <%= resource_name.to_s.humanize %>

+ +<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+ <%= f.label :email %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email" %> +
+ + <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %> +
Currently waiting confirmation for: <%= resource.unconfirmed_email %>
+ <% end %> + +
+ <%= f.label :password %> (leave blank if you don't want to change it)
+ <%= f.password_field :password, autocomplete: "new-password" %> + <% if @minimum_password_length %> +
+ <%= @minimum_password_length %> characters minimum + <% end %> +
+ +
+ <%= f.label :password_confirmation %>
+ <%= f.password_field :password_confirmation, autocomplete: "new-password" %> +
+ +
+ <%= f.label :current_password %> (we need your current password to confirm your changes)
+ <%= f.password_field :current_password, autocomplete: "current-password" %> +
+ +
+ <%= f.submit "Update" %> +
+<% end %> + +

Cancel my account

+ +

Unhappy? <%= button_to "Cancel my account", registration_path(resource_name), data: { confirm: "Are you sure?" }, method: :delete %>

+ +<%= link_to "Back", :back %> diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb new file mode 100644 index 00000000..d655b66f --- /dev/null +++ b/app/views/devise/registrations/new.html.erb @@ -0,0 +1,29 @@ +

Sign up

+ +<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+ <%= f.label :email %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email" %> +
+ +
+ <%= f.label :password %> + <% if @minimum_password_length %> + (<%= @minimum_password_length %> characters minimum) + <% end %>
+ <%= f.password_field :password, autocomplete: "new-password" %> +
+ +
+ <%= f.label :password_confirmation %>
+ <%= f.password_field :password_confirmation, autocomplete: "new-password" %> +
+ +
+ <%= f.submit "Sign up" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb new file mode 100644 index 00000000..5ede9648 --- /dev/null +++ b/app/views/devise/sessions/new.html.erb @@ -0,0 +1,26 @@ +

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 %> + +<%= render "devise/shared/links" %> 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/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb new file mode 100644 index 00000000..96a94124 --- /dev/null +++ b/app/views/devise/shared/_links.html.erb @@ -0,0 +1,25 @@ +<%- if controller_name != 'sessions' %> + <%= link_to "Log in", new_session_path(resource_name) %>
+<% end %> + +<%- if devise_mapping.registerable? && controller_name != 'registrations' %> + <%= link_to "Sign up", new_registration_path(resource_name) %>
+<% end %> + +<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> + <%= link_to "Forgot your password?", new_password_path(resource_name) %>
+<% end %> + +<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %> + <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>
+<% end %> + +<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> + <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
+<% end %> + +<%- if devise_mapping.omniauthable? %> + <%- resource_class.omniauth_providers.each do |provider| %> + <%= link_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), method: :post %>
+ <% end %> +<% end %> diff --git a/app/views/devise/unlocks/new.html.erb b/app/views/devise/unlocks/new.html.erb new file mode 100644 index 00000000..ffc34de8 --- /dev/null +++ b/app/views/devise/unlocks/new.html.erb @@ -0,0 +1,16 @@ +

Resend unlock instructions

+ +<%= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + +
+ <%= f.label :email %>
+ <%= f.email_field :email, autofocus: true, autocomplete: "email" %> +
+ +
+ <%= f.submit "Resend unlock instructions" %> +
+<% end %> + +<%= render "devise/shared/links" %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 331a7ed0..00af84ef 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -5,15 +5,13 @@ Top News <%= csrf_meta_tags %> <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> - <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> + <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> + + -

- <%= notice %> -

-

- <%= alert %> -

- <%= yield %> +
+ <%= yield %> +
diff --git a/app/views/news_stories/_news_list.html.erb b/app/views/news_stories/_news_list.html.erb new file mode 100644 index 00000000..0d00dfed --- /dev/null +++ b/app/views/news_stories/_news_list.html.erb @@ -0,0 +1,6 @@ +
+
    + <%= render partial: 'news_story', collection: news_stories, as: :news_story, locals: { start_count: pagy.from } %> +
+
+<%== pagy_bootstrap_nav(pagy) %> \ No newline at end of file diff --git a/app/views/news_stories/_news_story.html.erb b/app/views/news_stories/_news_story.html.erb new file mode 100644 index 00000000..e234926d --- /dev/null +++ b/app/views/news_stories/_news_story.html.erb @@ -0,0 +1,21 @@ +
  • +
    + <%= link_to news_story.title, news_story.url %> +
    +
    + <%= 'By: ' + news_story.by %> +
    +
    + <%= format_time(news_story.time) %> +
    + <% unless news_story.pinned_by_id.nil?%> +
    + <%= "Pinned By: " + news_story.fullname_of_pinner %> +
    + <% end %> +
    + <%= link_to "#", onclick: "pinStory(#{news_story.id}, this, event)", class: "pin-button #{disable_unpinning(news_story)}", data: { turbolinks: false } do %> + <%= icon('fas', 'thumbtack', class: "#{thumbtack_class(news_story)}") %> + <% end %> +
    +
  • \ No newline at end of file diff --git a/app/views/news_stories/index.html.erb b/app/views/news_stories/index.html.erb new file mode 100644 index 00000000..2b35f932 --- /dev/null +++ b/app/views/news_stories/index.html.erb @@ -0,0 +1,31 @@ +
    +
    +
    +

    Hacker News!

    +
    + <% if user_signed_in? %> + Signed in as: <%= current_user.email %> + <%= button_to "Sign out", destroy_user_session_path, method: :delete, class: "btn btn-outline-danger" %> + <% else %> + <%= link_to "Sign in", new_user_session_path, class: "btn btn-outline-primary" %> + <% end %> +
    +
    + +
    +
    +
    + <%= render 'news_list', news_stories: @news_stories, pagy: @pagy_ns %> +
    +
    + <%= render 'news_list', news_stories: @pinned_news_stories, pagy: @pagy_ps %> +
    +
    +
    \ No newline at end of file diff --git a/app/views/news_stories/index.js.erb b/app/views/news_stories/index.js.erb new file mode 100644 index 00000000..a27e2a2b --- /dev/null +++ b/app/views/news_stories/index.js.erb @@ -0,0 +1,5 @@ +<% if params[:page] %> + document.querySelector('#all-news').innerHTML = '<%= j render "news_list", news_stories: @news_stories, pagy: @pagy_ns %>' +<% elsif params[:page_pinned] %> + document.querySelector('#pinned-news').innerHTML = '<%= j render "news_list", news_stories: @pinned_news_stories, pagy: @pagy_ps %>' +<% end %> \ No newline at end of file diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index 8bfd8294..e69de29b 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -1 +0,0 @@ -

    Welcome to Top News

    diff --git a/bin/sidekiq b/bin/sidekiq new file mode 100755 index 00000000..24d2ddd0 --- /dev/null +++ b/bin/sidekiq @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'sidekiq' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("sidekiq", "sidekiq") diff --git a/bin/sidekiqmon b/bin/sidekiqmon new file mode 100755 index 00000000..641a10d6 --- /dev/null +++ b/bin/sidekiqmon @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'sidekiqmon' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("sidekiq", "sidekiqmon") diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 962d4a7c..7bfcfc64 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -1,5 +1,11 @@ # frozen_string_literal: true +# Assuming you have not yet modified this file, each configuration option below +# is set to its default value. Note that some are commented out while others +# are not: uncommented lines are intended to protect your configuration from +# breaking changes in upgrades (i.e., in the event that future versions of +# Devise change the default values for those options). +# # Use this hook to configure devise mailer, warden hooks and so forth. # Many of these configuration options can be set straight in your model. Devise.setup do |config| @@ -8,7 +14,11 @@ # confirmation, reset password and unlock tokens in the database. # Devise will use the `secret_key_base` as its `secret_key` # by default. You can change it below and use your own secret key. - # config.secret_key = '3d8fdc791713e92d72d135adbaa58657b3661394b046a293632d59b4cf4ba76fc5369d8f67202bce93a62fc0aacf24878bcd5f7879d859b588919783efcf7eb3' + # config.secret_key = 'f15fc571b09978ec9b4b150066460dd8c30bfea2fb84c303c74727d08a4e5b4d1a2ca0b1da14c63f3e593684518480a8e944834cb140de372b6681601b1b2684' + + # ==> Controller configuration + # Configure the parent class to the devise controllers. + # config.parent_controller = 'DeviseController' # ==> Mailer Configuration # Configure the e-mail address which will be shown in Devise::Mailer, @@ -64,7 +74,10 @@ # Tell if authentication through HTTP Auth is enabled. False by default. # It can be set to an array that will enable http authentication only for the # given strategies, for example, `config.http_authenticatable = [:database]` will - # enable it only for database authentication. The supported strategies are: + # enable it only for database authentication. + # For API-only applications to support authentication "out-of-the-box", you will likely want to + # enable this with :database unless you are using a custom strategy. + # The supported strategies are: # :database = Support basic authentication with authentication key + password # config.http_authenticatable = false @@ -99,18 +112,21 @@ # config.reload_routes = true # ==> Configuration for :database_authenticatable - # For bcrypt, this is the cost for hashing the password and defaults to 11. If + # For bcrypt, this is the cost for hashing the password and defaults to 12. If # using other algorithms, it sets how many times you want the password to be hashed. + # The number of stretches used for generating the hashed password are stored + # with the hashed password. This allows you to change the stretches without + # invalidating existing passwords. # # Limiting the stretches to just one in testing will increase the performance of # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use # a value less than 10 in other environments. Note that, for bcrypt (the default # algorithm), the cost increases exponentially with the number of stretches (e.g. # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation). - config.stretches = Rails.env.test? ? 1 : 11 + config.stretches = Rails.env.test? ? 1 : 12 # Set up a pepper to generate the hashed password. - # config.pepper = '98f368775fc8e418915aa4d2185d5659bda9b2a2aae35a6bb5c2863536c1c7e1ff8884578d43324639ae4d9bc4c851a7c71739103378841fd7f8f9e0db32daea' + # config.pepper = '81838797249821f4921b4e38e8e46424066d9c5cd53278f98a5b23c594297a13977341a0b56745193ee09d4cc88e8f72654be742f87da73461e2abf6d7c290d5' # Send a notification to the original email when the user's email is changed. # config.send_email_changed_notification = false @@ -122,8 +138,11 @@ # A period that the user is allowed to access the website even without # confirming their account. For instance, if set to 2.days, the user will be # able to access the website for two days without confirming their account, - # access will be blocked just in the third day. Default is 0.days, meaning - # the user cannot access the website without confirming their account. + # access will be blocked just in the third day. + # You can also set it to nil, which will allow the user to access the website + # without confirming their account. + # Default is 0.days, meaning the user cannot access the website without + # confirming their account. # config.allow_unconfirmed_access_for = 2.days # A period that the user is allowed to confirm their account before their @@ -276,4 +295,17 @@ # When using OmniAuth, Devise cannot automatically set OmniAuth path, # so you need to do it manually. For the users scope, it would be: # config.omniauth_path_prefix = '/my_engine/users/auth' + + # ==> Turbolinks configuration + # If your app is using Turbolinks, Turbolinks::Controller needs to be included to make redirection work correctly: + # + # ActiveSupport.on_load(:devise_failure_app) do + # include Turbolinks::Controller + # end + + # ==> Configuration for :registerable + + # When set to false, does not sign a user in automatically after their password is + # changed. Defaults to true, so a user is signed in automatically after changing a password. + # config.sign_in_after_change_password = true end diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb new file mode 100644 index 00000000..ad376863 --- /dev/null +++ b/config/initializers/pagy.rb @@ -0,0 +1,4 @@ +require 'pagy/extras/bootstrap' # if you're using Bootstrap + +Pagy::DEFAULT[:items] = 25 # items per page +Pagy::DEFAULT[:size] = 10 # nav bar links \ No newline at end of file diff --git a/config/initializers/scheduler.rb b/config/initializers/scheduler.rb new file mode 100644 index 00000000..22fa49b1 --- /dev/null +++ b/config/initializers/scheduler.rb @@ -0,0 +1,8 @@ +Rails.application.config.after_initialize do + if defined?(Sidekiq::Scheduler) + Sidekiq::Scheduler.instance.reload_schedule! + Rails.logger.info "Scheduled FetchNewsStoriesJob to run every #{ENV.fetch('POLLING_INTERVAL', '1m')}" + else + Rails.logger.warn "Sidekiq::Scheduler not available. FetchNewsStoriesJob not scheduled." + end +end \ No newline at end of file diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb new file mode 100644 index 00000000..79385504 --- /dev/null +++ b/config/initializers/sidekiq.rb @@ -0,0 +1,20 @@ +require 'sidekiq' +require 'sidekiq-scheduler' + +Sidekiq.configure_server do |config| + config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') } + + config.on(:startup) do + Sidekiq.schedule = { + 'fetch_news_stories' => { + 'every' => ENV.fetch('POLLING_INTERVAL', '3m'), + 'class' => 'FetchNewsStoriesJob' + } + } + SidekiqScheduler::Scheduler.instance.reload_schedule! + end +end + +Sidekiq.configure_client do |config| + config.redis = { url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0') } +end \ No newline at end of file diff --git a/config/initializers/wrap_parameters.rb b/config/initializers/wrap_parameters.rb index bbfc3961..e55389b0 100644 --- a/config/initializers/wrap_parameters.rb +++ b/config/initializers/wrap_parameters.rb @@ -5,7 +5,7 @@ # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. ActiveSupport.on_load(:action_controller) do - wrap_parameters format: [:json] + wrap_parameters format: [] end # To enable root element in JSON for ActiveRecord objects. diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml index 0b8f1302..260e1c4b 100644 --- a/config/locales/devise.en.yml +++ b/config/locales/devise.en.yml @@ -1,4 +1,4 @@ -# Additional translations at https://github.com/plataformatec/devise/wiki/I18n +# Additional translations at https://github.com/heartcombo/devise/wiki/I18n en: devise: @@ -42,8 +42,9 @@ en: signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." - update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirm link to confirm your new email address." + update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address." updated: "Your account has been updated successfully." + updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again." sessions: signed_in: "Signed in successfully." signed_out: "Signed out successfully." diff --git a/config/routes.rb b/config/routes.rb index c12ef082..8cdc8f12 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,9 @@ Rails.application.routes.draw do devise_for :users - root to: 'pages#home' + root to: 'news_stories#index' + resources :news_stories do + member do + post :pin + end + end end diff --git a/config/sidekiq.yml b/config/sidekiq.yml new file mode 100644 index 00000000..d84d6bc7 --- /dev/null +++ b/config/sidekiq.yml @@ -0,0 +1,4 @@ +:concurrency: 5 +:queues: + - default + - mailers \ No newline at end of file diff --git a/config/sidekiq_schedule.yml b/config/sidekiq_schedule.yml new file mode 100644 index 00000000..ddc6d76a --- /dev/null +++ b/config/sidekiq_schedule.yml @@ -0,0 +1,3 @@ +fetch_news_stories: + every: '30m' + class: FetchExternalDataJob \ No newline at end of file diff --git a/db/migrate/20240816170739_add_news_story.rb b/db/migrate/20240816170739_add_news_story.rb new file mode 100644 index 00000000..bd1a3d09 --- /dev/null +++ b/db/migrate/20240816170739_add_news_story.rb @@ -0,0 +1,16 @@ +class AddNewsStory < ActiveRecord::Migration[7.0] + def change + create_table :news_stories, id: false do |t| + t.integer :id, primary_key: true, null: false + t.text :title + t.string :type + t.string :url + t.integer :score + t.jsonb :descendants + t.string :by + t.references :pinned_by + t.integer :time + t.timestamps + end + end +end diff --git a/db/migrate/20240816173734_update_news_story_type.rb b/db/migrate/20240816173734_update_news_story_type.rb new file mode 100644 index 00000000..e96f6d51 --- /dev/null +++ b/db/migrate/20240816173734_update_news_story_type.rb @@ -0,0 +1,6 @@ +class UpdateNewsStoryType < ActiveRecord::Migration[7.0] + def change + remove_column :news_stories, :type, :string + add_column :news_stories, :story_type, :string + end +end diff --git a/db/migrate/20240816175023_update_news_story_time.rb b/db/migrate/20240816175023_update_news_story_time.rb new file mode 100644 index 00000000..75cc97ac --- /dev/null +++ b/db/migrate/20240816175023_update_news_story_time.rb @@ -0,0 +1,5 @@ +class UpdateNewsStoryTime < ActiveRecord::Migration[7.0] + def change + change_column :news_stories, :time, :bigint + end +end diff --git a/db/schema.rb b/db/schema.rb index acc34f3b..1a7ffa68 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,24 @@ # # 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_16_175023) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "news_stories", id: :serial, force: :cascade do |t| + t.text "title" + t.string "url" + t.integer "score" + t.jsonb "descendants" + t.string "by" + t.bigint "pinned_by_id" + t.bigint "time" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "story_type" + t.index ["pinned_by_id"], name: "index_news_stories_on_pinned_by_id" + end + create_table "users", force: :cascade do |t| t.string "first_name" t.string "last_name" diff --git a/db/seeds.rb b/db/seeds.rb index 231b90ad..e2ec21a2 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -15,3 +15,41 @@ last_name: 'Williams', password: 'Aechugh1ie' }) + +def news_story_ids + news_story_ids_uri = URI('https://hacker-news.firebaseio.com/v0/newstories.json') + news_story_ids_response = Net::HTTP.get(news_story_ids_uri) + + JSON.parse(news_story_ids_response) +end + +def news_stories + news_stories = news_story_ids + .map do |news_story_id| + news_story_id_uri = URI("https://hacker-news.firebaseio.com/v0/item/#{news_story_id}.json") + news_story_response = Net::HTTP.get(news_story_id_uri) + + JSON.parse(news_story_response) + end + .sort_by { |news_story| news_story['time'] } + .reverse + .map do |news_story| + NewsStory.create( + id: news_story["id"], + title: news_story["title"], + story_type: news_story["type"], + url: news_story["url"], + score: news_story["score"], + descendants: news_story["descendants"], + by: news_story["by"], + time: news_story["time"] + ) + + news_story + end +end + +news_stories + + + diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 00000000..fb57ccd1 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +