thumbsy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thumbsy
4
+ module Api
5
+ class Configuration
6
+ attr_accessor :require_authentication, :authentication_method, :current_voter_method, :require_authorization,
7
+ :authorization_method, :voter_serializer
8
+
9
+ def initialize
10
+ @require_authentication = true
11
+ @require_authorization = false
12
+ @authentication_method = nil
13
+ @current_voter_method = nil
14
+ @authorization_method = nil
15
+ @voter_serializer = nil
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thumbsy
4
+ module Api
5
+ class ApplicationController < ActionController::API
6
+ before_action :authenticate_voter!, if: :authentication_required?
7
+ before_action :set_current_voter
8
+
9
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
10
+
11
+ protected
12
+
13
+ def authenticate_voter!
14
+ if Thumbsy::Api.authentication_method
15
+ instance_eval(&Thumbsy::Api.authentication_method)
16
+ else
17
+ head :unauthorized unless current_voter.present?
18
+ end
19
+ end
20
+
21
+ def set_current_voter
22
+ if Thumbsy::Api.current_voter_method
23
+ @current_voter = instance_eval(&Thumbsy::Api.current_voter_method)
24
+ elsif respond_to?(:current_user)
25
+ @current_voter = current_user
26
+ end
27
+ end
28
+
29
+ attr_reader :current_voter
30
+
31
+ def authentication_required?
32
+ Thumbsy::Api.require_authentication
33
+ end
34
+
35
+ def render_success(data = {}, status = :ok)
36
+ render json: { data: data }, status: status
37
+ end
38
+
39
+ def render_error(message, status = :bad_request, errors = {})
40
+ # Convert errors to array format
41
+ error_array = if errors.is_a?(Hash) && errors.any?
42
+ errors.values.flatten
43
+ else
44
+ []
45
+ end
46
+ render json: { error: message, errors: error_array }, status: status
47
+ end
48
+
49
+ def render_not_found(_exception)
50
+ render_error("Resource not found", :not_found)
51
+ end
52
+
53
+ def render_unauthorized(message = "Authentication required")
54
+ render_error(message, :unauthorized)
55
+ end
56
+
57
+ def render_forbidden(message = "Access denied")
58
+ render_error(message, :forbidden)
59
+ end
60
+
61
+ def render_unprocessable_entity(message = "Validation failed", errors = {})
62
+ render_error(message, :unprocessable_entity, errors)
63
+ end
64
+
65
+ def render_bad_request(message = "Bad request")
66
+ render_error(message, :bad_request)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thumbsy
4
+ module Api
5
+ class VotesController < Thumbsy::Api::ApplicationController
6
+ before_action :find_votable
7
+ before_action :check_votable_permissions, if: :authorization_required?
8
+
9
+ # POST /votes/vote_up
10
+ def vote_up
11
+ vote = @votable.vote_up(current_voter, comment: vote_params[:comment],
12
+ feedback_options: vote_params[:feedback_options])
13
+
14
+ if vote&.persisted? && vote.errors.empty?
15
+ render_success(Thumbsy::Api::Serializers::VoteSerializer.new(vote).as_json, :created)
16
+ else
17
+ render_unprocessable_entity("Failed to create vote", vote&.errors&.full_messages || [])
18
+ end
19
+ end
20
+
21
+ # POST /votes/vote_down
22
+ def vote_down
23
+ vote = @votable.vote_down(current_voter, comment: vote_params[:comment],
24
+ feedback_options: vote_params[:feedback_options])
25
+
26
+ if vote&.persisted? && vote.errors.empty?
27
+ render_success(Thumbsy::Api::Serializers::VoteSerializer.new(vote).as_json, :created)
28
+ else
29
+ render_unprocessable_entity("Failed to create vote", vote&.errors&.full_messages || [])
30
+ end
31
+ end
32
+
33
+ # DELETE /votes/remove
34
+ def remove
35
+ removed = @votable.remove_vote(current_voter)
36
+
37
+ if removed
38
+ render_success({ message: "Vote removed" })
39
+ else
40
+ render_not_found(nil)
41
+ end
42
+ end
43
+
44
+ # GET /votes/status
45
+ def status
46
+ vote = @votable.vote_by(current_voter)
47
+
48
+ data = {
49
+ voted: @votable.voted_by?(current_voter),
50
+ vote_type: if vote
51
+ vote.up_vote? ? "up" : "down"
52
+ end,
53
+ comment: vote&.comment,
54
+ vote_counts: {
55
+ total: @votable.votes_count,
56
+ up: @votable.up_votes_count,
57
+ down: @votable.down_votes_count,
58
+ score: @votable.votes_score,
59
+ },
60
+ }
61
+
62
+ render_success(data)
63
+ end
64
+
65
+ # GET /votes/vote
66
+ def show
67
+ vote = @votable.vote_by(current_voter)
68
+ if vote
69
+ render_success(Thumbsy::Api::Serializers::VoteSerializer.new(vote).as_json)
70
+ else
71
+ render_not_found(nil)
72
+ end
73
+ end
74
+
75
+ # GET /votes
76
+ def index
77
+ votes = filtered_votes
78
+ data = {
79
+ votes: votes.map { |vote| Thumbsy::Api::Serializers::VoteSerializer.new(vote).as_json },
80
+ summary: vote_summary,
81
+ }
82
+
83
+ render_success(data)
84
+ end
85
+
86
+ private
87
+
88
+ def filtered_votes
89
+ votes = @votable.thumbsy_votes.includes(:voter)
90
+ votes = votes.with_comments if params[:with_comments] == "true"
91
+ votes = votes.up_votes if params[:vote_type] == "up"
92
+ votes = votes.down_votes if params[:vote_type] == "down"
93
+ votes
94
+ end
95
+
96
+ def vote_summary
97
+ {
98
+ total: @votable.votes_count,
99
+ up: @votable.up_votes_count,
100
+ down: @votable.down_votes_count,
101
+ score: @votable.votes_score,
102
+ }
103
+ end
104
+
105
+ def find_votable
106
+ votable_class = params[:votable_type].constantize
107
+ @votable = votable_class.find(params[:votable_id])
108
+ rescue NameError
109
+ render_bad_request("Invalid votable type")
110
+ rescue ActiveRecord::RecordNotFound
111
+ render_not_found(nil)
112
+ end
113
+
114
+ def check_votable_permissions
115
+ return unless Thumbsy::Api.authorization_method
116
+
117
+ authorized = instance_exec(@votable, current_voter, &Thumbsy::Api.authorization_method)
118
+ render_forbidden unless authorized
119
+ end
120
+
121
+ def authorization_required?
122
+ Thumbsy::Api.require_authorization
123
+ end
124
+
125
+ def vote_params
126
+ permitted = params.permit(:comment, { feedback_options: [] }, :feedback_option, :votable_type, :votable_id)
127
+ # Normalize feedback_option (string) to feedback_options (array)
128
+ if permitted[:feedback_options].blank? && permitted[:feedback_option].present?
129
+ permitted[:feedback_options] = [permitted.delete(:feedback_option)]
130
+ end
131
+ permitted
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thumbsy
4
+ module Api
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Thumbsy::Api
7
+
8
+ # Load API routes from routes.rb file
9
+ config.after_initialize do
10
+ require "thumbsy/api/routes"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ Thumbsy::Api::Engine.routes.draw do
4
+ # Flexible routes that can be mounted anywhere
5
+ scope ":votable_type/:votable_id" do
6
+ post "votes/vote_up", to: "votes#vote_up"
7
+ post "votes/vote_down", to: "votes#vote_down"
8
+ delete "votes/remove", to: "votes#remove"
9
+ get "votes/status", to: "votes#status"
10
+ get "votes/vote", to: "votes#show"
11
+ get "votes", to: "votes#index"
12
+
13
+ # Alternative shorter routes
14
+ post "vote_up", to: "votes#vote_up"
15
+ post "vote_down", to: "votes#vote_down"
16
+ delete "vote", to: "votes#remove"
17
+ get "vote", to: "votes#show"
18
+ end
19
+
20
+ # Bulk operations (optional)
21
+ resources :votes, only: %i[index show] do
22
+ collection do
23
+ get :summary
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thumbsy
4
+ module Api
5
+ module Serializers
6
+ class VoteSerializer
7
+ def initialize(vote)
8
+ @vote = vote
9
+ end
10
+
11
+ def as_json
12
+ {
13
+ id: @vote.id,
14
+ vote_type: @vote.up_vote? ? "up" : "down",
15
+ comment: @vote.comment,
16
+ feedback_options: @vote.feedback_options || [],
17
+ voter: voter_data(@vote.voter),
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ def voter_data(voter)
24
+ if Thumbsy::Api.voter_serializer
25
+ instance_exec(voter, &Thumbsy::Api.voter_serializer)
26
+ else
27
+ {
28
+ id: voter.id,
29
+ type: voter.class.name,
30
+ }
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thumbsy
4
+ module Api
5
+ def self.require_authentication
6
+ Thumbsy.api_config.require_authentication
7
+ end
8
+
9
+ def self.authentication_method
10
+ Thumbsy.api_config.authentication_method
11
+ end
12
+
13
+ def self.current_voter_method
14
+ Thumbsy.api_config.current_voter_method
15
+ end
16
+
17
+ def self.require_authorization
18
+ Thumbsy.api_config.require_authorization
19
+ end
20
+
21
+ def self.require_authorization=(value)
22
+ Thumbsy.api_config.require_authorization = value
23
+ end
24
+
25
+ def self.authorization_method
26
+ Thumbsy.api_config.authorization_method
27
+ end
28
+
29
+ def self.authorization_method=(value)
30
+ Thumbsy.api_config.authorization_method = value
31
+ end
32
+
33
+ def self.voter_serializer
34
+ Thumbsy.api_config.voter_serializer
35
+ end
36
+
37
+ def self.voter_serializer=(value)
38
+ Thumbsy.api_config.voter_serializer = value
39
+ end
40
+
41
+ def self.configure
42
+ yield(Thumbsy.api_config)
43
+ end
44
+
45
+ # Load API components
46
+ def self.load!
47
+ require "thumbsy/api/engine"
48
+ require "thumbsy/api/controllers/application_controller"
49
+ require "thumbsy/api/controllers/votes_controller"
50
+ require "thumbsy/api/serializers/vote_serializer"
51
+ end
52
+
53
+ # Custom exceptions
54
+ class AuthenticationError < StandardError; end
55
+ class AuthorizationError < StandardError; end
56
+ end
57
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thumbsy
4
+ class Configuration
5
+ attr_accessor :api_config
6
+
7
+ def initialize
8
+ @api_config = Thumbsy::Api::Configuration.new
9
+ end
10
+
11
+ def api
12
+ yield @api_config if block_given?
13
+ @api_config
14
+ end
15
+
16
+ def feedback_options=(options)
17
+ @feedback_options = options
18
+ # Automatically set up validation if ThumbsyVote is loaded
19
+ ThumbsyVote.setup_feedback_options_validation! if defined?(ThumbsyVote)
20
+ end
21
+
22
+ attr_reader :feedback_options
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thumbsy
4
+ # Only define Rails engine when Rails is available
5
+ if defined?(Rails)
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace Thumbsy
8
+
9
+ config.generators do |g|
10
+ g.test_framework :rspec
11
+ end
12
+
13
+ initializer "thumbsy.active_record" do
14
+ ActiveSupport.on_load(:active_record) do
15
+ extend Thumbsy::Extension
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thumbsy
4
+ module Extension
5
+ def votable(_options = {})
6
+ include Thumbsy::Votable
7
+ end
8
+
9
+ def voter(_options = {})
10
+ include Thumbsy::Voter
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require_relative "../validators/array_inclusion_validator"
5
+
6
+ class ThumbsyVote < ActiveRecord::Base
7
+ serialize :feedback_options
8
+
9
+ after_initialize :set_default_feedback_options
10
+
11
+ def feedback_options=(value)
12
+ super(value.nil? ? [] : value)
13
+ end
14
+
15
+ # Dynamically (re)setup feedback_options validation
16
+ def self.setup_feedback_options_validation!
17
+ return unless Thumbsy.feedback_options.present?
18
+
19
+ _validators.delete(:feedback_options)
20
+ validates :feedback_options, array_inclusion: { in: Thumbsy.feedback_options }, allow_nil: false
21
+ end
22
+
23
+ belongs_to :votable, polymorphic: true
24
+ belongs_to :voter, polymorphic: true
25
+
26
+ validates :votable, presence: true
27
+ validates :voter, presence: true
28
+ validates :vote, inclusion: { in: [true, false] }
29
+ validates :voter_id, uniqueness: { scope: %i[voter_type votable_type votable_id] }
30
+
31
+ scope :up_votes, -> { where(vote: true) }
32
+ scope :down_votes, -> { where(vote: false) }
33
+ scope :with_comments, -> { where.not(comment: [nil, ""]) }
34
+
35
+ def up_vote?
36
+ vote == true
37
+ end
38
+
39
+ def down_vote?
40
+ vote == false
41
+ end
42
+
43
+ def self.vote_for(votable, voter, vote_value, comment: nil, feedback_options: nil)
44
+ raise ArgumentError, "Voter cannot be nil" if voter.nil?
45
+ raise ArgumentError, "Votable cannot be nil" if votable.nil?
46
+
47
+ existing_vote = find_by(
48
+ votable: votable,
49
+ voter: voter,
50
+ )
51
+
52
+ if existing_vote
53
+ existing_vote.update!(
54
+ vote: vote_value,
55
+ comment: comment,
56
+ feedback_options: feedback_options,
57
+ )
58
+ existing_vote
59
+ else
60
+ create!(
61
+ votable: votable,
62
+ voter: voter,
63
+ vote: vote_value,
64
+ comment: comment,
65
+ feedback_options: feedback_options,
66
+ )
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def set_default_feedback_options
73
+ self.feedback_options = [] if feedback_options.nil?
74
+ end
75
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thumbsy
4
+ class InvalidFeedbackOptionError < ArgumentError; end
5
+ end
6
+
7
+ module ActiveModel
8
+ module Validations
9
+ class ArrayInclusionValidator < EachValidator
10
+ def validate_each(record, attribute, value)
11
+ return if value.nil?
12
+
13
+ allowed = options[:in] || []
14
+ return if value.is_a?(Array) && value.all? { |v| allowed.include?(v) }
15
+
16
+ record.errors.add(attribute, "contains invalid feedback option(s)")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thumbsy
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thumbsy
4
+ module Votable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ has_many :thumbsy_votes, as: :votable, class_name: "ThumbsyVote", dependent: :destroy
9
+ scope :with_votes, -> { joins(:thumbsy_votes) }
10
+ scope :with_up_votes, -> { joins(:thumbsy_votes).where(thumbsy_votes: { vote: true }) }
11
+ scope :with_down_votes, -> { joins(:thumbsy_votes).where(thumbsy_votes: { vote: false }) }
12
+ scope :with_comments, -> { joins(:thumbsy_votes).where.not(thumbsy_votes: { comment: [nil, ""] }) }
13
+ end
14
+
15
+ def vote_up(voter, comment: nil, feedback_options: nil)
16
+ return false if voter && !voter.respond_to?(:thumbsy_votes)
17
+
18
+ ThumbsyVote.vote_for(self, voter, true, comment: comment, feedback_options: feedback_options)
19
+ rescue ActiveRecord::RecordInvalid => e
20
+ e.record # Return the invalid vote instance with errors
21
+ end
22
+
23
+ def vote_down(voter, comment: nil, feedback_options: nil)
24
+ raise ArgumentError, "Voter is invalid" if voter && !voter.respond_to?(:thumbsy_votes)
25
+
26
+ ThumbsyVote.vote_for(self, voter, false, comment: comment, feedback_options: feedback_options)
27
+ rescue ActiveRecord::RecordInvalid => e
28
+ e.record # Return the invalid vote instance with errors
29
+ end
30
+
31
+ # rubocop:disable Naming/PredicateMethod
32
+ def remove_vote(voter)
33
+ destroyed = thumbsy_votes.where(voter: voter).destroy_all
34
+ destroyed.any?
35
+ end
36
+ # rubocop:enable Naming/PredicateMethod
37
+
38
+ def voted_by?(voter)
39
+ thumbsy_votes.exists?(voter: voter)
40
+ end
41
+
42
+ def up_voted_by?(voter)
43
+ thumbsy_votes.exists?(voter: voter, vote: true)
44
+ end
45
+
46
+ def down_voted_by?(voter)
47
+ thumbsy_votes.exists?(voter: voter, vote: false)
48
+ end
49
+
50
+ def vote_by(voter)
51
+ thumbsy_votes.find_by(voter: voter)
52
+ end
53
+
54
+ def votes_count
55
+ thumbsy_votes.count
56
+ end
57
+
58
+ def up_votes_count
59
+ thumbsy_votes.where(vote: true).count
60
+ end
61
+
62
+ def down_votes_count
63
+ thumbsy_votes.where(vote: false).count
64
+ end
65
+
66
+ def votes_score
67
+ up_votes_count - down_votes_count
68
+ end
69
+
70
+ def votes_with_comments
71
+ thumbsy_votes.where.not(comment: [nil, ""])
72
+ end
73
+
74
+ def up_votes_with_comments
75
+ thumbsy_votes.where(vote: true).where.not(comment: [nil, ""])
76
+ end
77
+
78
+ def down_votes_with_comments
79
+ thumbsy_votes.where(vote: false).where.not(comment: [nil, ""])
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thumbsy
4
+ module Voter
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ has_many :thumbsy_votes, as: :voter, class_name: "ThumbsyVote", dependent: :destroy
9
+ end
10
+
11
+ def vote_up_for(votable, comment: nil)
12
+ return false unless votable.respond_to?(:thumbsy_votes)
13
+
14
+ votable.vote_up(self, comment: comment)
15
+ end
16
+
17
+ def vote_down_for(votable, comment: nil)
18
+ return false unless votable.respond_to?(:thumbsy_votes)
19
+
20
+ votable.vote_down(self, comment: comment)
21
+ end
22
+
23
+ def remove_vote_for(votable)
24
+ return false unless votable.respond_to?(:thumbsy_votes)
25
+
26
+ votable.remove_vote(self)
27
+ end
28
+
29
+ def voted_for?(votable)
30
+ return false unless votable.respond_to?(:thumbsy_votes)
31
+
32
+ votable.voted_by?(self)
33
+ end
34
+
35
+ def up_voted_for?(votable)
36
+ return false unless votable.respond_to?(:thumbsy_votes)
37
+
38
+ votable.up_voted_by?(self)
39
+ end
40
+
41
+ def down_voted_for?(votable)
42
+ return false unless votable.respond_to?(:thumbsy_votes)
43
+
44
+ votable.down_voted_by?(self)
45
+ end
46
+
47
+ def voted_for(votable_class)
48
+ vote_records = thumbsy_votes.where(votable_type: votable_class.name)
49
+ votable_ids = vote_records.pluck(:votable_id)
50
+ votable_class.where(id: votable_ids)
51
+ end
52
+
53
+ def up_voted_for_class(votable_class)
54
+ vote_records = thumbsy_votes.where(votable_type: votable_class.name, vote: true)
55
+ votable_ids = vote_records.pluck(:votable_id)
56
+ votable_class.where(id: votable_ids)
57
+ end
58
+
59
+ def down_voted_for_class(votable_class)
60
+ vote_records = thumbsy_votes.where(votable_type: votable_class.name, vote: false)
61
+ votable_ids = vote_records.pluck(:votable_id)
62
+ votable_class.where(id: votable_ids)
63
+ end
64
+ end
65
+ end