ruby-on-railsactiverecordscopepaper-trail-gemuser-generated-content

Building an Active Record scope of 'editor approved' versions of user-generated content


I run a site similar to StackOverflow where users can publish content (Posts). There also is a frontpage showcasing featured Posts picked by our editors.

I want to allow our users to edit their Posts anytime, while still making sure nothing appears on our frontpage without it first being reviewed by our editors.

To solve this, I figured we could implement a versioning system like paper_trail and store the current version_id whenever we feature a Post. This way we can simply fetch the editor-reviewed content on the frontpage, while still showing the most recent version on less critical parts of the site such as the user's profile. Our editors can periodically review any changes and approve those.

I'm wondering what the cleanest approach is that allows me to select reviewed versions in some controllers and the latest versions in others while keeping a shared interface and minimally duplicated code.


The ideal solution would involve a reviewed scope so I could simply do Post.reviewed and get back the reviewed versions, and Post.all to get the latest versions.

I'm not sure how to go about this though, as getting the reviewed version requires de-serializing the object PaperTrail stores (version.reify) and that doesn't seem possible within a scope.

I could use a class method instead like this:

def self.reviewed all.map do |post| Version.find(post.version_id).reify end.compact! || Post.none end

However, this is less than ideal as it's not a real scope and thus not chainable, etc.

Alternatively I could use a Post instance method like:

def reviewed_version Version.find(version_id).reify end

This works in theory, but this means I now have to call this method all over my views, whereas it's the responsibility of the controller to fetch the right data. Let's say I have a render collection: @posts on both the frontpage and user profile, how does my _post.html.erb partial know whether to call reviewed_version or not.


I'm not tied to PaperTrail, but it has a lot of niceties I prefer not having to duplicate. I'm open to exploring other directions if PaperTrail proves too inflexible however.

FWIW, I have looked at Draftsman as well, but it considers the 'draft' as the exception (i.e. in most cases you wouldn't show the draft), whereas in my case I want to show the most recent version on most pages, except for a particular few like the frontpage.


Solution

  • This sounds over complicated for doing something that is quite simple by itself:

    If you want to find the last reviewed revision for a Post, you can have:

    class Post < ActiveRecord::Base
      has_many :revisions
    
      delegate :content, to: :current_revision # you can even have those to transparently delegate
    
      def current_revision
        @current_revision ||= revisions.where( reviewed: true ).order( "created_at desc" ).first
      end
    end
    

    if you want to retrieve only Posts which have reviewed revisions:

    class Post < ActiveRecord::Base
      has_many :revisions
    
      scope :reviewed, ->{ group( 'posts.id' ) ).having( 'count(revisions.id) > 0' ).joins( :revisions ).where( "revisions.reviewed = ?", true ) }
    end
    

    This is how I would have done it, let people more familiar with paper_trail tell you about its way :)

    EDIT

    As discussed in comments, a decorator may also help to differentiate the revision and delegate methods to it, while still being able to pass an activerecord relation. Which give something in that way:

    class Post < ActiveRecord::Base
      has_many :revisions
    
      scope :reviewed, ->{ group( 'posts.id' ) ).having( 'count(revisions.id) > 0' ).joins( :revisions ).where( "revisions.reviewed = ?", true ) }
    
      def reviewed_revision
        @reviewed_revision ||= revisions.where( reviewed: true ).order( "created_at desc" ).first
      end
    
      def latest_revision
        @latest_revision ||= revisions.order( "created_at desc" ).first
      end
    end
    
    class PostDecorator
      attr_reader :post, :revision
      delegate :content, :updated_at, to: :revision
    
      def self.items( scope, revision_method )
        revision_method = :reviewed_revision unless %i[ reviewed_revision latest_revision ].include?( revision_method )
    
        scope.map do |post|
          PostDecorator.new( post, post.send( revision_method ) )
        end
      end
    
      def initalize( post, revision )
        @post, @revision = post, revision
      end
    
      def method_missing( action_name, *args, &block )
        post.send( action_name, *args, &block )
      end
    end
    
    # view
    #
    # <% PostDecorator.items( Post.reviewed.limit(10), :reviewed_revision ).each do |post| %>
    # <p><%= post.author.name %></p>
    # <p><%= simple_format post.content %></p>
    # <% end %>