diff --git a/Gemfile b/Gemfile index 96b2d3d..5098c0d 100644 --- a/Gemfile +++ b/Gemfile @@ -56,3 +56,5 @@ end group :test do gem 'simplecov', '~> 0.22.0', require: false end + +gem 'bcrypt' \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index ab98758..0db6436 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -73,6 +73,7 @@ GEM descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) + bcrypt (3.1.20) bootsnap (1.16.0) msgpack (~> 1.2) builder (3.2.4) @@ -228,6 +229,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + bcrypt bootsnap (~> 1.16) byebug did_you_mean (~> 1.6, >= 1.6.3) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 501f0b9..22dd920 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -2,34 +2,7 @@ class ApplicationController < ActionController::API include ActionController::HttpAuthentication::Token::ControllerMethods - - rescue_from ActionController::ParameterMissing, with: :show_parameter_missing_error - - protected - - def authenticate_user(&block) - return block&.call if current_user - - head :unauthorized - end - - def current_user - @current_user ||= authenticate_with_http_token do |token| - User.find_by(token: token) - end - end - - def render_json(status, json = {}) - render status: status, json: json - end - - def show_parameter_missing_error(exception) - render_json(400, error: exception.message) - end - - def set_todo_lists - user_todo_lists = current_user.todo_lists - - @todo_lists = todo_lists_only_non_default? ? user_todo_lists.non_default : user_todo_lists - end + include Authenticatable + include ErrorHandleable + include Renderable end diff --git a/app/controllers/concerns/authenticatable.rb b/app/controllers/concerns/authenticatable.rb new file mode 100644 index 0000000..338480e --- /dev/null +++ b/app/controllers/concerns/authenticatable.rb @@ -0,0 +1,15 @@ +module Authenticatable + protected + + def authenticate_user(&block) + return block&.call if current_user + + head :unauthorized + end + + def current_user + @current_user ||= authenticate_with_http_token do |token| + User.find_by(token: token) + end + end +end \ No newline at end of file diff --git a/app/controllers/concerns/error_handleable.rb b/app/controllers/concerns/error_handleable.rb new file mode 100644 index 0000000..60bd269 --- /dev/null +++ b/app/controllers/concerns/error_handleable.rb @@ -0,0 +1,13 @@ +module ErrorHandleable + extend ActiveSupport::Concern + + included do + rescue_from ActionController::ParameterMissing, with: :show_parameter_missing_error + end + + protected + + def show_parameter_missing_error(exception) + render_json(:bad_request, error: exception.message) + end + end \ No newline at end of file diff --git a/app/controllers/concerns/renderable.rb b/app/controllers/concerns/renderable.rb new file mode 100644 index 0000000..89b32be --- /dev/null +++ b/app/controllers/concerns/renderable.rb @@ -0,0 +1,5 @@ +module Renderable + def render_json(status, json = {}) + render status: status, json: json + end +end \ No newline at end of file diff --git a/app/controllers/concerns/todo_listable.rb b/app/controllers/concerns/todo_listable.rb new file mode 100644 index 0000000..9b95272 --- /dev/null +++ b/app/controllers/concerns/todo_listable.rb @@ -0,0 +1,9 @@ +module TodoListable + protected + + def set_todo_lists + user_todo_lists = current_user.todo_lists + + @todo_lists = todo_lists_only_non_default? ? user_todo_lists.non_default : user_todo_lists + end +end \ No newline at end of file diff --git a/app/controllers/todo_lists_controller.rb b/app/controllers/todo_lists_controller.rb index 91dd55f..9384eb6 100644 --- a/app/controllers/todo_lists_controller.rb +++ b/app/controllers/todo_lists_controller.rb @@ -1,48 +1,49 @@ # frozen_string_literal: true class TodoListsController < ApplicationController + include TodoListable before_action :authenticate_user before_action :set_todo_lists before_action :set_todo_list, except: [:index, :create] rescue_from ActiveRecord::RecordNotFound do |not_found| - render_json(404, todo_list: { id: not_found.id, message: 'not found' }) + render_json(:not_found, todo_list: { id: not_found.id, message: 'not found' }) end def index - todo_lists = @todo_lists.order_by(params).map(&:serialize_as_json) + todo_lists = TodoListsOrderQuery.new(@todo_lists, params).call.map(&:serialize_as_json) - render_json(200, todo_lists: todo_lists) + render_json(:ok, todo_lists: todo_lists) end def create todo_list = @todo_lists.create(todo_list_params) if todo_list.valid? - render_json(201, todo_list: todo_list.serialize_as_json) + render_todo_list_json(:created, todo_list: todo_list) else - render_json(422, todo_list: todo_list.errors.as_json) + render_todo_list_json(:unprocessable_entity, errors: todo_list) end end def show - render_json(200, todo_list: @todo_list.serialize_as_json) + render_todo_list_json(:ok, todo_list: @todo_list) end def destroy @todo_list.destroy - render_json(200, todo_list: @todo_list.serialize_as_json) + render_todo_list_json(:ok, todo_list: @todo_list) end def update @todo_list.update(todo_list_params) if @todo_list.valid? - render_json(200, todo_list: @todo_list.serialize_as_json) + render_todo_list_json(:ok, todo_list: @todo_list) else - render_json(422, todo_list: @todo_list.errors.as_json) + render_todo_list_json(:unprocessable_entity, errors: @todo_list) end end @@ -57,4 +58,8 @@ def set_todo_list def todo_list_params params.require(:todo_list).permit(:title) end + + def render_todo_list_json(status, todo_list: nil, errors: nil) + render_json(status, todo_list: todo_list ? todo_list.serialize_as_json : errors.errors.as_json) + end end diff --git a/app/controllers/todos_controller.rb b/app/controllers/todos_controller.rb index 87a358a..2b24a03 100644 --- a/app/controllers/todos_controller.rb +++ b/app/controllers/todos_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class TodosController < ApplicationController + include TodoListable before_action :authenticate_user before_action :set_todo_lists @@ -10,55 +11,56 @@ class TodosController < ApplicationController rescue_from ActiveRecord::RecordNotFound do |not_found| key = not_found.model == 'TodoList' ? :todo_list : :todo - render_json(404, key => { id: not_found.id, message: 'not found' }) + render_json(:not_found, key => { id: not_found.id, message: 'not found' }) end def index - todos = @todos.filter_by_status(params).order_by(params).map(&:serialize_as_json) + filter = TodoFilterQuery.new(@todos, params).call + todos = TodoOrderQuery.new(filter, params).call.map(&:serialize_as_json) - render_json(200, todos:) + render_json(:ok, todos:) end def create todo = @todos.create(todo_params.except(:completed)) if todo.valid? - render_json(201, todo: todo.serialize_as_json) + render_todo_json(:created, todo: todo) else - render_json(422, todo: todo.errors.as_json) + render_json(:unprocessable_entity, todo: todo.errors.as_json) end end def show - render_json(200, todo: @todo.serialize_as_json) + render_todo_json(:ok, todo: @todo) end def destroy @todo.destroy - render_json(200, todo: @todo.serialize_as_json) + render_json(:ok, todo: @todo.serialize_as_json) end def update @todo.update(todo_params) if @todo.valid? - render_json(200, todo: @todo.serialize_as_json) + render_todo_json(:ok, todo: @todo) else - render_json(422, todo: @todo.errors.as_json) + render_json(:unprocessable_entity, todo: @todo.errors.as_json) end end def complete - @todo.complete! + TodoCompleter.new(@todo).call - render_json(200, todo: @todo.serialize_as_json) + render_todo_json(:ok, todo: @todo) end def incomplete - @todo.incomplete! + TodoIncompleter.new(@todo).call - render_json(200, todo: @todo.serialize_as_json) + render_todo_json(:ok, todo: @todo) end private @@ -66,14 +68,12 @@ def incomplete def todo_lists_only_non_default? = false def set_todos - scope = + @todos = if params[:todo_list_id].present? - @todo_lists.find(params[:todo_list_id]) + @todo_lists.find(params[:todo_list_id]).todos else - action_name == 'create' ? @todo_lists.default.first! : current_user + default_or_user_todos end - - @todos = scope.todos end def set_todo @@ -83,4 +83,12 @@ def set_todo def todo_params params.require(:todo).permit(:title, :due_at, :completed) end + + def render_todo_json(status, todo: nil, errors: nil) + render_json(status, todo: todo ? todo.serialize_as_json : { errors: errors }) + end + + def default_or_user_todos + action_name == 'create' ? @todo_lists.default.first!.todos : current_user.todos + end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index bb29b9e..f52557c 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,57 +1,33 @@ # frozen_string_literal: true class UsersController < ApplicationController - def create - user_params = params.require(:user).permit(:name, :email, :password, :password_confirmation) - - password = user_params[:password].to_s.strip - password_confirmation = user_params[:password_confirmation].to_s.strip + before_action :authenticate_user, only: [:show, :destroy] - errors = {} - errors[:password] = ["can't be blank"] if password.blank? - errors[:password_confirmation] = ["can't be blank"] if password_confirmation.blank? + def create + form = UserForm.new(user_params) - if errors.present? - render_json(422, user: errors) + if form.save + render_json(:created, user: form.user.as_json(only: [:id, :name, :token])) else - if password != password_confirmation - render_json(422, user: { password_confirmation: ["doesn't match password"] }) - else - password_digest = Digest::SHA256.hexdigest(password) - - user = User.new( - name: user_params[:name], - email: user_params[:email], - token: SecureRandom.uuid, - password_digest: password_digest - ) - - if user.save - render_json(201, user: user.as_json(only: [:id, :name, :token])) - else - render_json(422, user: user.errors.as_json) - end - end + render_json(:unprocessable_entity, user: form.errors.as_json) end end def show - perform_if_authenticated + render_json(:ok, user: { email: current_user.email }) end def destroy - perform_if_authenticated do - current_user.destroy + if current_user.destroy + render_json(:ok, user: { email: current_user.email }) + else + render_json(:unprocessable_entity, errors: current_user.errors.as_json) end end private - def perform_if_authenticated(&block) - authenticate_user do - block.call if block - - render_json(200, user: { email: current_user.email }) - end - end + def user_params + params.require(:user).permit(:name, :email, :password, :password_confirmation) + end end diff --git a/app/forms/user_form.rb b/app/forms/user_form.rb new file mode 100644 index 0000000..8a5cc7f --- /dev/null +++ b/app/forms/user_form.rb @@ -0,0 +1,43 @@ +class UserForm + include ActiveModel::Model + + attr_accessor :name, :email, :password, :password_confirmation, :user + + validates :password, presence: true + validates :password_confirmation, presence: true + validate :password_confirmation_matches, if: -> { password.present? && password_confirmation.present? } + + + def save + return false unless valid? + + service = UserSignupService.new( + name: name, + email: email, + token: token, + password_digest: password_digest + ) + + if service.call + @user = service.user + true + else + errors.merge!(service.user.errors) + false + end + end + + private + + def password_digest + Digest::SHA256.hexdigest(password) + end + + def token + SecureRandom.uuid + end + + def password_confirmation_matches + errors.add(:password_confirmation, "doesn't match password") if password != password_confirmation + end +end \ No newline at end of file diff --git a/app/interactions/todo_completer.rb b/app/interactions/todo_completer.rb new file mode 100644 index 0000000..0e139d8 --- /dev/null +++ b/app/interactions/todo_completer.rb @@ -0,0 +1,9 @@ +class TodoCompleter + def initialize(todo) + @todo = todo + end + + def call + @todo.complete! + end + end \ No newline at end of file diff --git a/app/interactions/todo_incompleter.rb b/app/interactions/todo_incompleter.rb new file mode 100644 index 0000000..72ca0f6 --- /dev/null +++ b/app/interactions/todo_incompleter.rb @@ -0,0 +1,10 @@ +class TodoIncompleter + def initialize(todo) + @todo = todo + end + + def call + @todo.incomplete! + end + end + \ No newline at end of file diff --git a/app/interactions/user_signup_service.rb b/app/interactions/user_signup_service.rb new file mode 100644 index 0000000..ff1de05 --- /dev/null +++ b/app/interactions/user_signup_service.rb @@ -0,0 +1,28 @@ +class UserSignupService + attr_reader :user + + def initialize(user_params) + @user_params = user_params + @user = User.new(user_params) + end + + def call + if @user.save + create_default_todo_list + send_welcome_email + return user + else + return false + end + end + + private + + def send_welcome_email + UserMailer.with(user: @user).welcome.deliver_later + end + + def create_default_todo_list + @user.todo_lists.create!(title: 'Default', default: true) + end +end diff --git a/app/models/concerns/completable.rb b/app/models/concerns/completable.rb new file mode 100644 index 0000000..f79ead1 --- /dev/null +++ b/app/models/concerns/completable.rb @@ -0,0 +1,24 @@ +module Completable + extend ActiveSupport::Concern + + included do + scope :completed, -> { where.not(completed_at: nil) } + scope :incomplete, -> { where(completed_at: nil) } + end + + def complete! + update(completed_at: Time.current) unless completed? + end + + def incomplete! + update(completed_at: nil) if completed? + end + + def incomplete? + completed_at.nil? + end + + def completed? + completed_at.present? + end +end \ No newline at end of file diff --git a/app/models/concerns/overdoable.rb b/app/models/concerns/overdoable.rb new file mode 100644 index 0000000..6ce63de --- /dev/null +++ b/app/models/concerns/overdoable.rb @@ -0,0 +1,13 @@ +module Overdoable + extend ActiveSupport::Concern + + included do + scope :overdue, -> { incomplete.where('due_at <= ?', Time.current) } + end + + def overdue? + return false if !due_at || completed_at + + due_at <= Time.current + end +end \ No newline at end of file diff --git a/app/models/todo.rb b/app/models/todo.rb index 0fc5943..34c124c 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -1,58 +1,20 @@ # frozen_string_literal: true class Todo < ApplicationRecord - attr_accessor :completed - + include Completable + include Overdoable + belongs_to :todo_list, required: true, inverse_of: :todos has_one :user, through: :todo_list - scope :overdue, -> { incomplete.where('due_at <= ?', Time.current) } - scope :completed, -> { where.not(completed_at: nil) } - scope :incomplete, -> { where(completed_at: nil) } - - scope :filter_by_status, ->(params) { - case params[:status]&.strip&.downcase - when 'overdue' then overdue - when 'completed' then completed - when 'incomplete' then incomplete - else all - end - } - - scope :order_by, ->(params) { - order = params[:order]&.strip&.downcase == 'asc' ? :asc : :desc - - sort_by = params[:sort_by]&.strip&.downcase - - column_name = column_names.excluding('id').include?(sort_by) ? sort_by : 'id' - - order(column_name => order) - } - - before_validation on: :update do - case completed - when 'true' then complete - when 'false' then incomplete - end - end - validates :title, presence: true - validates :due_at, presence: true, allow_nil: true - validates :completed_at, presence: true, allow_nil: true - - def overdue? - return false if !due_at || completed_at - - due_at <= Time.current - end - def incomplete? - completed_at.nil? - end - - def completed? - !incomplete? + def completed=(value) + case value.to_s + when 'true' then complete! + when 'false' then incomplete! + end end def status @@ -62,26 +24,6 @@ def status 'incomplete' end - def complete - self.completed_at = Time.current unless completed? - end - - def complete! - complete - - self.save if completed_at_changed? - end - - def incomplete - self.completed_at = nil unless incomplete? - end - - def incomplete! - incomplete - - self.save if completed_at_changed? - end - def serialize_as_json as_json(except: [:completed], methods: :status) end diff --git a/app/models/todo_list.rb b/app/models/todo_list.rb index 085dcdf..a234214 100644 --- a/app/models/todo_list.rb +++ b/app/models/todo_list.rb @@ -8,27 +8,11 @@ class TodoList < ApplicationRecord scope :default, -> { where(default: true) } scope :non_default, -> { where(default: false) } - scope :order_by, ->(params) { - order = params[:order]&.strip&.downcase == 'asc' ? :asc : :desc - - sort_by = params[:sort_by]&.strip&.downcase - - column_name = column_names.excluding('id', 'user_id').include?(sort_by) ? sort_by : 'id' - - order(column_name => order) - } - validates :title, presence: true validates :default, inclusion: { in: [true, false] } - validate :default_uniqueness + validates :default, uniqueness: { scope: :user_id, message: 'already exists' }, if: :default? def serialize_as_json as_json(except: [:user_id]) end - - private - - def default_uniqueness - errors.add(:default, 'already exists') if default? && user.todo_lists.default.exists? - end end diff --git a/app/models/user.rb b/app/models/user.rb index 92fffe3..8eb825c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,26 +1,13 @@ # frozen_string_literal: true class User < ApplicationRecord + has_secure_password has_many :todo_lists, dependent: :destroy, inverse_of: :user has_many :todos, through: :todo_lists - has_one :default_todo_list, ->(user) { user.todo_lists.default }, class_name: 'TodoList' + has_one :default_todo_list, -> { default }, class_name: 'TodoList' validates :name, presence: true validates :email, presence: true, format: URI::MailTo::EMAIL_REGEXP, uniqueness: true validates :token, presence: true, length: { is: 36 }, uniqueness: true - validates :password_digest, presence: true, length: { is: 64 } - - after_create :create_default_todo_list - after_commit :send_welcome_email, on: :create - - private - - def create_default_todo_list - todo_lists.create!(title: 'Default', default: true) - end - - def send_welcome_email - UserMailer.with(user: self).welcome.deliver_later - end end diff --git a/app/queries/base_order_query.rb b/app/queries/base_order_query.rb new file mode 100644 index 0000000..f570557 --- /dev/null +++ b/app/queries/base_order_query.rb @@ -0,0 +1,27 @@ +class BaseOrderQuery + # Available columns to order by to prevent SQL injection + ALLOWED_SORT_COLUMNS = [].freeze + DEFAULT_SORT_COLUMN = 'id'.freeze + DEFAULT_SORT_ORDER = :desc.freeze + + def initialize(relation, params = {}) + @relation = relation + @params = params + end + + def call + @relation.order(sort_column => sort_order) + end + + protected + + def sort_column + column = @params[:sort_by]&.strip&.downcase + self.class::ALLOWED_SORT_COLUMNS.include?(column) ? column : self.class::DEFAULT_SORT_COLUMN + end + + def sort_order + @params[:order]&.strip&.downcase == 'asc' ? :asc : self.class::DEFAULT_SORT_ORDER + end +end + \ No newline at end of file diff --git a/app/queries/todo_filter_query.rb b/app/queries/todo_filter_query.rb new file mode 100644 index 0000000..8bc9d57 --- /dev/null +++ b/app/queries/todo_filter_query.rb @@ -0,0 +1,25 @@ +class TodoFilterQuery + def initialize(relation, params = {}) + @relation = relation + @params = params + end + + def call + filter_by_status + end + + private + + def filter_by_status + case @params[:status]&.strip&.downcase + when 'overdue' + @relation.overdue + when 'completed' + @relation.completed + when 'incomplete' + @relation.incomplete + else + @relation + end + end +end \ No newline at end of file diff --git a/app/queries/todo_lists_order_query.rb b/app/queries/todo_lists_order_query.rb new file mode 100644 index 0000000..d32a6af --- /dev/null +++ b/app/queries/todo_lists_order_query.rb @@ -0,0 +1,5 @@ +class TodoListsOrderQuery < BaseOrderQuery + # Available columns to order by to prevent SQL injection + ALLOWED_SORT_COLUMNS = ['created_at', 'updated_at', 'title'].freeze +end + \ No newline at end of file diff --git a/app/queries/todo_order_query.rb b/app/queries/todo_order_query.rb new file mode 100644 index 0000000..81feaa4 --- /dev/null +++ b/app/queries/todo_order_query.rb @@ -0,0 +1,4 @@ +class TodoOrderQuery < BaseOrderQuery + # Available columns to order by to prevent SQL injection + ALLOWED_SORT_COLUMNS = ['due_at', 'created_at', 'updated_at', 'title', 'completed_at', 'todo_list_id'].freeze +end \ No newline at end of file