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
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.1.2
3.1.0
8 changes: 8 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,11 @@ gem 'turbolinks'
gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
gem 'uglifier'
gem 'web-console', group: :development
gem 'jwt'
gem 'foreman', '~> 0.87.2', group: :development
gem 'rack-cors'
group :development, :test do
gem 'rspec-rails'
gem 'factory_bot_rails'
gem 'faker'
end
2 changes: 2 additions & 0 deletions Procfile.dev
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
web: cd client && PORT=3000 npm start
api: PORT=3001 bundle exec rails s
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,51 @@ When a team member signs in, they will see recent news stories and be able to st
* 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.

## Installation

1. Clone the repository:
<pre>
git clone https://github.com/wali89/topnews.git
cd topnews
</pre>

2. Backend setup:
<pre>
bundle install
rails db:create db:migrate db:seed
</pre>

3. Set up environment variables: Create a .env file in the root directory and add the following:
<pre>
JWT_SECRET=your_jwt_secret_here
</pre>

4. Frontend setup:
<pre>
cd ../client
npm install
</pre>

## Running the Application
<pre>
foreman start -f Procfile.dev
</pre>

## Running the test
From the topnews project directory you can run the test suite with:
<pre>
rspec
</pre>

To run the frontend tests:
<pre>
cd ../client
npm test
</pre>

## API Endpoints
* POST /api/v1/login: Authenticate a user and receive a JWT
* GET /api/v1/user_stories: Retrieve all user stories for the authenticated user
* POST /api/v1/user_stories: Create a new user story
* DELETE /api/v1/user_stories/:id: Delete a user story
20 changes: 20 additions & 0 deletions app/controllers/api/v1/auth_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
class Api::V1::AuthController < ApplicationController
skip_before_action :verify_authenticity_token

def login
user = User.find_by(email: params[:email].downcase)

if user.nil?
render json: { error: 'User not found' }, status: :unauthorized
elsif user.valid_password?(params[:password])
token = JsonWebToken.encode(user_id: user.id)
render json: { token: token, user_id: user.id }, status: :ok
else
render json: { error: 'Invalid password' }, status: :unauthorized
end
end

def logout
render json: { message: 'Logged out successfully' }, status: :ok
end
end
14 changes: 14 additions & 0 deletions app/controllers/api/v1/base_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module Api
module V1
class BaseController < ActionController::Base
protect_from_forgery with: :null_session
before_action :set_csrf_cookie

private

def set_csrf_cookie
cookies['CSRF-TOKEN'] = form_authenticity_token
end
end
end
end
78 changes: 78 additions & 0 deletions app/controllers/api/v1/user_stories_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
module Api
module V1
class UserStoriesController < BaseController
include Authenticate

before_action :authenticate_request
before_action :set_user_story, only: [:destroy]

def index
grouped_stories = Story.joins(:user_stories, :users)
.select('stories.*, array_agg(DISTINCT users.email) as user_emails')
.group('stories.id')
.order('stories.created_at DESC')

render json: {
current_user_email: @current_user.email,
stories: grouped_stories.map { |story| story_json(story) }
}
rescue StandardError => e
render_error(e, :internal_server_error)
end

def create
story = find_or_create_story
user_story = UserStory.find_or_create_by(story: story, user: @current_user)

if user_story.persisted?
render json: user_story, include: :story, status: :created
else
render_error(user_story.errors, :unprocessable_entity)
end
rescue ActiveRecord::RecordInvalid => e
render_error(e.record.errors, :unprocessable_entity)
end


def destroy
if @user_story
@user_story.destroy
render json: { message: 'User story successfully deleted' }, status: :ok
else
render_error('User story not found', :not_found)
end
end

private

def set_user_story
@user_story = @current_user.user_stories.find_by(story_id: params[:id])
end

def find_or_create_story
Story.find_or_create_by!(hn_id: params[:story_id]) do |s|
s.title = params[:title]
s.url = params[:url]
end
rescue ActiveRecord::RecordInvalid => e
raise e
end

def story_json(story)
{
id: story.id,
title: story.title,
url: story.url,
hn_id: story.hn_id,
user_emails: story.user_emails,
current_user_starred: story.user_emails.include?(@current_user.email)
}
end

def render_error(error, status)
error_message = error.is_a?(ActiveModel::Errors) ? error.full_messages.join(', ') : error.to_s
render json: { error: error_message }, status: status
end
end
end
end
9 changes: 8 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
end
skip_before_action :verify_authenticity_token, if: :json_request?

protected

def json_request?
request.format.json?
end
end
43 changes: 43 additions & 0 deletions app/controllers/concerns/authenticate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module Authenticate
extend ActiveSupport::Concern

included do
before_action :authenticate_request
end

private

def authenticate_request
@current_user = decoded_user
render_unauthorized unless @current_user
end

def decoded_user
return nil unless token

decoded = JsonWebToken.decode(token)
User.find(decoded[:user_id])
rescue JWT::DecodeError
log_error("Invalid token")
nil
rescue ActiveRecord::RecordNotFound
log_error("User not found")
nil
rescue StandardError => e
log_error("Unexpected authentication error: #{e.message}")
nil
end

def token
header = request.headers['Authorization']
header.split(' ').last if header
end

def render_unauthorized
render json: { error: 'Unauthorized' }, status: :unauthorized
end

def log_error(message)
Rails.logger.error("Authentication error: #{message}")
end
end
8 changes: 8 additions & 0 deletions app/models/story.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class Story < ApplicationRecord
has_many :user_stories
has_many :users, through: :user_stories

validates :hn_id, presence: true, uniqueness: true
validates :title, presence: true
validates :url, presence: true
end
3 changes: 3 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ class User < ApplicationRecord
# :confirmable, :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable

has_many :user_stories
has_many :stories, through: :user_stories
end
4 changes: 4 additions & 0 deletions app/models/user_story.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class UserStory < ApplicationRecord
belongs_to :user
belongs_to :story
end
3 changes: 3 additions & 0 deletions client/.babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
Loading