diff --git a/.circleci/config.yml b/.circleci/config.yml
index 3e59eb6372373313908432c21d28ac9ca144f914..a7e7aa2f66d129a940b4ecd0ce5aa4644281ca59 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,6 +1,6 @@
 version: 2.1
 jobs:
-  build:
+  test:
     docker:
       - image: circleci/ruby:2.6.6-node
       - image: circleci/mysql:8.0.20
@@ -50,12 +50,49 @@ jobs:
           command: |
             git rev-parse $(git rev-parse --abbrev-ref HEAD)
       - run:
-          name: Rubocop
+          name: Coveralls token
           command: |
-            bundle exec rubocop
+            echo "repo_token: $COVERALLS_REPO_TOKEN" > .coveralls.yml
       - run:
           name: Test
           command: |
             bundle exec rails test
       - store_test_results:
-          path: "~/qpixel/test/reports"
\ No newline at end of file
+          path: "~/qpixel/test/reports"
+
+  rubocop:
+    docker:
+      - image: circleci/ruby:2.6.6-node
+
+    working_directory: ~/qpixel
+
+    steps:
+      - run:
+          name: Install packages
+          command: |
+            sudo apt-get -qq update
+            sudo apt-get -y install git libmariadb-dev libmagickwand-dev
+      - checkout
+      - restore_cache:
+          keys:
+            - qpixel-{{ checksum "Gemfile" }}
+            - qpixel-
+      - run:
+          name: Install Bundler & gems
+          command: |
+            gem install bundler
+            bundle install --path=~/gems
+      - save_cache:
+          key: qpixel-{{ checksum "Gemfile" }}
+          paths:
+            - ~/gems
+      - run:
+          name: Rubocop
+          command: |
+            bundle exec rubocop
+
+workflows:
+  test_lint:
+    jobs:
+      - test
+      - rubocop
\ No newline at end of file
diff --git a/.codeclimate.yml b/.codeclimate.yml
deleted file mode 100644
index 8ac2189bd861fb80e3d77be946777a787d06f1cb..0000000000000000000000000000000000000000
--- a/.codeclimate.yml
+++ /dev/null
@@ -1,46 +0,0 @@
----
-engines:
-  brakeman:
-    enabled: true
-  bundler-audit:
-    enabled: true
-  csslint:
-    enabled: true
-  coffeelint:
-    enabled: true
-  duplication:
-    enabled: true
-    config:
-      languages:
-        ruby:
-          mass_threshold: 70
-        javascript:
-          mass_threshold: 70
-  eslint:
-    enabled: true
-  fixme:
-    enabled: true
-  rubocop:
-    enabled: true
-ratings:
-  paths:
-  - Gemfile.lock
-  - "**.erb"
-  - "**.haml"
-  - "**.rb"
-  - "**.rhtml"
-  - "**.slim"
-  - "**.css"
-  - "**.coffee"
-  - "**.inc"
-  - "**.js"
-  - "**.jsx"
-  - "**.module"
-  - "**.php"
-  - "**.py"
-exclude_paths:
-- config/
-- db/
-- test/
-- vendor/
-- doc/
diff --git a/.csslintrc b/.csslintrc
deleted file mode 100644
index aacba956e5bbede1c195ce554fcad3e200b24ff8..0000000000000000000000000000000000000000
--- a/.csslintrc
+++ /dev/null
@@ -1,2 +0,0 @@
---exclude-exts=.min.css
---ignore=adjoining-classes,box-model,ids,order-alphabetical,unqualified-attributes
diff --git a/.rubocop.yml b/.rubocop.yml
index ad7500dc2509dcb8219df8afe28058129bc0af60..609087b50a5bbac7d28288da95b20035bcd23532 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -32,12 +32,14 @@ Metrics/AbcSize:
   Enabled: false
 Metrics/BlockLength:
   Max: 30
+Metrics/BlockNesting:
+  Max: 5
 Metrics/ClassLength:
   Max: 300
 Metrics/CyclomaticComplexity:
   Max: 30
 Metrics/MethodLength:
-  Max: 40
+  Max: 60
 Metrics/ModuleLength:
   Max: 200
 Metrics/PerceivedComplexity:
diff --git a/Gemfile b/Gemfile
index 5eba0d0af84ae98ecc5926f62c9ed358fd011aee..2a4ddc0cda16286149f558ca940cb5ccd08c8e78 100644
--- a/Gemfile
+++ b/Gemfile
@@ -47,6 +47,7 @@ gem 'whenever', '~> 1.0', require: false
 gem 'awesome_print', '~> 1.8'
 gem 'coveralls', '~> 0.8', require: false
 gem 'rubocop', '~> 1'
+gem 'rubocop-rails', '~> 2.9'
 
 # MiniProfiler support, including stack traces & memory dumps, plus flamegraphs.
 gem 'flamegraph', '~> 0.9'
diff --git a/Gemfile.lock b/Gemfile.lock
index 9e4ec448c69eef4dfeda03a8b51b33a9c9203e98..41884b63dfc10bd48e5434135370aa40700e6c2c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -236,6 +236,10 @@ GEM
       unicode-display_width (>= 1.4.0, < 2.0)
     rubocop-ast (1.3.0)
       parser (>= 2.7.1.5)
+    rubocop-rails (2.9.0)
+      activesupport (>= 4.2.0)
+      rack (>= 1.1)
+      rubocop (>= 0.90.0, < 2.0)
     ruby-enum (0.8.0)
       i18n
     ruby-progressbar (1.10.1)
@@ -341,6 +345,7 @@ DEPENDENCIES
   rotp (~> 6.0)
   rqrcode (~> 1.1)
   rubocop (~> 1)
+  rubocop-rails (~> 2.9)
   ruby-progressbar (~> 1.10)
   sass-rails (~> 5.0)
   spring (~> 2.1)
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 627f7c95585961bd5b1656301a2fe76d86f47636..5b5dc6f7da61799adce1fd059aa2b6cbecfe42c4 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -72,30 +72,31 @@ $(document).on('ready', function() {
     });
   });
 
-  $("a.close-dialog-link").on("click", (ev) => {
+  $('.close-dialog-link').on('click', (ev) => {
     ev.preventDefault();
     const self = $(ev.target);
     console.log(self.parents(".post--body").find(".js-close-box").toggleClass("is-active"));
   });
-  $("button.close-question").on("click", (ev) => {
+
+  $('.js-close-question').on('click', (ev) => {
     ev.preventDefault();
     const self = $(ev.target);
-    active_radio = self.parents(".js-close-box").find("input[type='radio'][name='close-reason']:checked");
+    const active_radio = self.parents('.js-close-box').find("input[type='radio'][name='close-reason']:checked");
     const data = {
       'reason_id': active_radio.val(),
-      'other_post': active_radio.parents(".widget--body").find(".js-close-other-post").val()
+      'other_post': active_radio.parents('.widget--body').find('.js-close-other-post').val()
       // option will be silently discarded if no input element
     };
 
-    if (data["other_post"]) {
-      if (data["other_post"].match(/\/[0-9]+$/)) {
-        data["other_post"] = data["other_post"].replace(/.*\/([0-9]+)$/, "$1");
+    if (data['other_post']) {
+      if (data['other_post'].match(/\/[0-9]+$/)) {
+        data['other_post'] = data['other_post'].replace(/.*\/([0-9]+)$/, "$1");
       }
     }
 
     $.ajax({
       'type': 'POST',
-      'url': '/questions/' + self.data("post-id") + '/close',
+      'url': '/posts/' + self.data('post-id') + '/close',
       'data': data,
       'target': self
     })
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
index a618fff16f4be724371704ef7887e8ed212afb91..80f1a12c0f7afea3c855b6938850d951de13035f 100644
--- a/app/controllers/admin_controller.rb
+++ b/app/controllers/admin_controller.rb
@@ -35,7 +35,7 @@ class AdminController < ApplicationController
     @ability.update("#{type}_score_threshold" => params[:threshold])
     AuditLog.admin_audit(event_type: 'ability_threshold_update', related: @ability, user: current_user,
                          comment: "#{params[:type]} score\nfrom <<#{pre}>>\nto <<#{params[:threshold]}>>")
-    render json: { status: 'OK', privilege: @ability }, status: 202
+    render json: { status: 'OK', privilege: @ability }, status: :accepted
   end
 
   def admin_email; end
diff --git a/app/controllers/advertisement_controller.rb b/app/controllers/advertisement_controller.rb
index b5b62aca3bf0422e1deb94471c196783bac0b482..e884040c62ad5229fb1053cccad9f50b71a1681a 100644
--- a/app/controllers/advertisement_controller.rb
+++ b/app/controllers/advertisement_controller.rb
@@ -2,9 +2,9 @@ require 'rmagick'
 require 'open-uri'
 
 # Necessary due to rmagick
-# rubocop:disable Metrics/ClassLength
 # rubocop:disable Metrics/MethodLength
 # rubocop:disable Metrics/BlockLength
+# rubocop:disable Metrics/ClassLength
 # noinspection RubyResolve, DuplicatedCode, RubyArgCount
 class AdvertisementController < ApplicationController
   include Magick
@@ -197,7 +197,7 @@ class AdvertisementController < ApplicationController
     if category.nil?
       category = Category.where(use_for_advertisement: true)
     end
-    Post.undeleted.where(last_activity: (Rails.env.development? ? 365 : 7).days.ago..Time.now)
+    Post.undeleted.where(last_activity: (Rails.env.development? ? 365 : 7).days.ago..Time.zone.now)
         .where(post_type_id: Question.post_type_id)
         .where(category: category)
         .where('score > ?', SiteSetting['HotPostsScoreThreshold'])
@@ -370,5 +370,5 @@ class AdvertisementController < ApplicationController
   end
 end
 # rubocop:enable Metrics/MethodLength
-# rubocop:enable Metrics/ClassLength
 # rubocop:enable Metrics/BlockLength
+# rubocop:enable Metrics/ClassLength
diff --git a/app/controllers/answers_controller.rb b/app/controllers/answers_controller.rb
index 6ee1aa676c2856aaa43cbcbf10f5dbfe30ae2667..a30ba3f64b51dff519403da0fb387232fd879511 100644
--- a/app/controllers/answers_controller.rb
+++ b/app/controllers/answers_controller.rb
@@ -1,166 +1,10 @@
 # Web controller. Provides actions that relate to answers. Pretty much the standard set of resources, really - it's
 # questions that have a few more actions.
 class AnswersController < ApplicationController
-  before_action :authenticate_user!, only: [:new, :create, :edit, :update, :destroy, :undelete, :convert_to_comment]
-  before_action :set_answer, only: [:edit, :update, :destroy, :undelete, :convert_to_comment]
+  before_action :authenticate_user!, only: [:convert_to_comment]
+  before_action :set_answer, only: [:convert_to_comment]
   before_action :verify_moderator, only: [:convert_to_comment]
-  before_action :check_if_answer_locked, only: [:edit, :update, :destroy, :undelete, :convert_to_comment]
-
-  def new
-    @answer = Answer.new
-    @question = Question.find params[:id]
-  end
-
-  def create
-    @question = Question.find params[:id]
-
-    @answer = Answer.new(answer_params.merge(parent: @question, user: current_user, score: 0,
-                                             body: helpers.post_markdown(:answer, :body_markdown),
-                                             last_activity: DateTime.now, last_activity_by: current_user,
-                                             category: @question.category))
-
-    recent_second_level_posts = Post.where(created_at: 24.hours.ago..Time.now, user: current_user)
-                                    .where(post_type_id: second_level_post_types).count
-
-    max_slps = SiteSetting[if current_user.privilege?('unrestricted')
-                             'RL_SecondLevelPosts'
-                           else
-                             'RL_NewUserSecondLevelPosts'
-                           end]
-
-    post_limit_msg = if current_user.privilege? 'unrestricted'
-                       "You may only post #{max_slps} answers per day."
-                     else
-                       "You may only post #{max_slps} answers per day. " \
-                       'Once you have some well-received posts, that limit will increase.'
-                     end
-
-    if recent_second_level_posts >= max_slps
-      @answer.errors.add :base, post_limit_msg
-      AuditLog.rate_limit_log(event_type: 'second_level_post', related: @question, user: current_user,
-                              comment: "limit: #{max_slps}\n\npost:\n#{@answer.attributes_print}")
-      render :new, status: 400
-      return
-    end
-
-    unless current_user.id == @question.user.id
-      @question.user.create_notification("New answer to your question '#{@question.title.truncate(50)}'",
-                                         share_question_url(@question))
-    end
-    if @answer.save
-      @question.update(last_activity: DateTime.now, last_activity_by: current_user)
-      unless current_user.id == @question.user.id
-        @question.user.create_notification("New answer to your question '#{@question.title.truncate(50)}'",
-                                           share_question_url(@question))
-      end
-      redirect_to url_for(controller: :questions, action: :show, id: params[:id])
-    else
-      render :new, status: 422
-    end
-  end
-
-  def edit; end
-
-  def update
-    can_post_in_category = @answer.parent.category.present? &&
-                           (@answer.parent.category.min_trust_level || -1) <= current_user&.trust_level
-    unless current_user&.has_post_privilege?('edit_posts', @answer) && can_post_in_category
-      return update_as_suggested_edit
-    end
-
-    if params[:answer][:body_markdown] == @answer.body_markdown
-      flash[:danger] = "No changes were saved because you didn't edit the post."
-      return redirect_to question_path(@answer.parent)
-    end
-
-    before = @answer.body_markdown
-    if @answer.update(answer_params.merge(body: helpers.post_markdown(:answer, :body_markdown),
-                                          last_activity: DateTime.now, last_activity_by: current_user,
-                                          last_edited_at: DateTime.now, last_edited_by: current_user,
-                                          license_id: @answer.license_id))
-      PostHistory.post_edited(@answer, current_user, before: before,
-                              after: params[:answer][:body_markdown], comment: params[:edit_comment])
-      redirect_to share_answer_path(qid: @answer.parent_id, id: @answer.id)
-    else
-      render :edit
-    end
-  end
-
-  def update_as_suggested_edit
-    return if check_edits_limit! @answer
-
-    if params[:answer][:body_markdown] == @answer.body_markdown
-      flash[:danger] = "No changes were saved because you didn't edit the post."
-      return redirect_to question_path(@answer.parent)
-    end
-
-    updates = {
-      post: @answer,
-      user: current_user,
-      community: @answer.community,
-      body: helpers.post_markdown(:answer, :body_markdown),
-      body_markdown: params[:answer][:body_markdown] == @answer.body_markdown ? nil : params[:answer][:body_markdown],
-      comment: params[:edit_comment],
-      active: true, accepted: false,
-      decided_at: nil, decided_by: nil,
-      rejected_comment: nil
-    }
-
-    @edit = SuggestedEdit.new(updates)
-    if @edit.save
-      @answer.user.create_notification("Edit suggested on your answer to #{@answer.parent.title.truncate(50)}",
-                                       share_answer_url(qid: @answer.parent_id, id: @answer.id))
-      redirect_to share_answer_path(qid: @answer.parent_id, id: @answer.id)
-    else
-      @answer.errors = @edit.errors
-      render :edit
-    end
-  end
-
-  def destroy
-    unless check_your_privilege('flag_curate', @answer, false)
-      flash[:danger] = helpers.ability_err_msg(:flag_curate, 'delete this answer')
-      redirect_to(question_path(@answer.parent)) && return
-    end
-
-    if @answer.deleted
-      flash[:danger] = "Can't delete a deleted answer."
-      redirect_to(question_path(@answer.parent)) && return
-    end
-
-    if @answer.update(deleted: true, deleted_at: DateTime.now, deleted_by: current_user,
-                      last_activity: DateTime.now, last_activity_by: current_user)
-      PostHistory.post_deleted(@answer, current_user)
-    else
-      flash[:danger] = "Can't delete this answer right now. Try again later."
-    end
-    redirect_to question_path(@answer.parent)
-  end
-
-  def undelete
-    unless check_your_privilege('flag_curate', @answer, false)
-      flash[:danger] = flash[:danger] = helpers.ability_err_msg(:flag_curate, 'undelete this answer')
-      redirect_to(question_path(@answer.parent)) && return
-    end
-
-    unless @answer.deleted
-      flash[:danger] = "Can't undelete an undeleted answer."
-      redirect_to(question_path(@answer.parent)) && return
-    end
-
-    if @answer.deleted_by.is_moderator && !current_user.is_moderator
-      flash[:danger] = 'You cannot undelete this post deleted by a moderator.'
-      redirect_to(question_path(@answer.parent)) && return
-    end
-
-    if @answer.update(deleted: false, deleted_at: nil, deleted_by: nil,
-                      last_activity: DateTime.now, last_activity_by: current_user)
-      PostHistory.post_undeleted(@answer, current_user)
-    else
-      flash[:danger] = "Can't undelete this answer right now. Try again later."
-    end
-    redirect_to question_path(@answer.parent)
-  end
+  before_action :check_if_answer_locked, only: [:convert_to_comment]
 
   def convert_to_comment
     text = @answer.body_markdown
@@ -176,10 +20,6 @@ class AnswersController < ApplicationController
 
   private
 
-  def answer_params
-    params.require(:answer).permit(:body_markdown, :license_id)
-  end
-
   def set_answer
     @answer = Answer.find params[:id]
   end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 9db1e2e55d48ef37165e3ace224a52e8296dbba1..527bc7b615495d8f9bab081a5d83669dc19b6c0e 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -10,6 +10,8 @@ class ApplicationController < ActionController::Base
   before_action :check_if_warning_or_suspension_pending
   before_action :stop_the_awful_troll
 
+  helper_method :top_level_post_types, :second_level_post_types
+
   def upload
     redirect_to helpers.upload_remote_url(params[:key])
   end
@@ -28,13 +30,29 @@ class ApplicationController < ActionController::Base
     devise_parameter_sanitizer.permit(:account_update, keys: [:username, :profile, :website, :twitter])
   end
 
-  def not_found
-    render 'errors/not_found', layout: 'without_sidebar', status: 404
+  def not_found(**add)
+    respond_to do |format|
+      format.html do
+        render 'errors/not_found', layout: 'without_sidebar', status: :not_found
+      end
+      format.json do
+        render json: { status: 'failed', success: false, errors: ['not_found'] }.merge(add), status: :not_found
+      end
+    end
+    false
   end
 
   def verify_moderator
     if !user_signed_in? || !(current_user.is_moderator || current_user.is_admin)
-      render 'errors/not_found', layout: 'without_sidebar', status: 404
+      respond_to do |format|
+        format.html do
+          render 'errors/not_found', layout: 'without_sidebar', status: :not_found
+        end
+        format.json do
+          render json: { status: 'failed', success: false, errors: ['not_found'] }, status: :not_found
+        end
+      end
+
       return false
     end
     true
@@ -42,7 +60,7 @@ class ApplicationController < ActionController::Base
 
   def verify_admin
     if !user_signed_in? || !current_user.is_admin
-      render 'errors/not_found', layout: 'without_sidebar', status: 404
+      render 'errors/not_found', layout: 'without_sidebar', status: :not_found
       return false
     end
     true
@@ -50,7 +68,7 @@ class ApplicationController < ActionController::Base
 
   def verify_global_admin
     if !user_signed_in? || !current_user.is_global_admin
-      render 'errors/not_found', layout: 'without_sidebar', status: 404
+      render 'errors/not_found', layout: 'without_sidebar', status: :not_found
       return false
     end
     true
@@ -58,7 +76,7 @@ class ApplicationController < ActionController::Base
 
   def verify_global_moderator
     if !user_signed_in? || !(current_user.is_global_moderator || current_user.is_global_admin)
-      render 'errors/not_found', layout: 'without_sidebar', status: 404
+      render 'errors/not_found', layout: 'without_sidebar', status: :not_found
       return false
     end
     true
@@ -67,7 +85,7 @@ class ApplicationController < ActionController::Base
   def check_your_privilege(name, post = nil, render_error = true)
     unless current_user&.privilege?(name) || (current_user&.has_post_privilege?(name, post) if post)
       @privilege = Ability.find_by(name: name)
-      render 'errors/forbidden', layout: 'without_sidebar', privilege_name: name, status: 403 if render_error
+      render 'errors/forbidden', layout: 'without_sidebar', privilege_name: name, status: :forbidden if render_error
       return false
     end
     true
@@ -78,22 +96,26 @@ class ApplicationController < ActionController::Base
 
     if post.locked?
       respond_to do |format|
-        format.html { render 'errors/locked', layout: 'without_sidebar', status: 401 }
-        format.json { render json: { status: 'failed', message: 'Post is locked.' }, status: 401 }
+        format.html { render 'errors/locked', layout: 'without_sidebar', status: :unauthorized }
+        format.json { render json: { status: 'failed', message: 'Post is locked.' }, status: :unauthorized }
       end
     end
   end
 
   def top_level_post_types
-    [Question.post_type_id, Article.post_type_id]
+    Rails.cache.fetch 'top_level_post_types' do
+      PostType.where(is_top_level: true).select(:id).map(&:id)
+    end
   end
 
   def second_level_post_types
-    [Answer.post_type_id]
+    Rails.cache.fetch 'second_level_post_types' do
+      PostType.where(is_top_level: false, has_parent: true).select(:id).map(&:id)
+    end
   end
 
   def check_edits_limit!(post)
-    recent_edits = SuggestedEdit.where(created_at: 24.hours.ago..Time.now, user: current_user) \
+    recent_edits = SuggestedEdit.where(created_at: 24.hours.ago..Time.zone.now, user: current_user) \
                                 .where('active = TRUE OR accepted = FALSE').count
 
     max_edits = SiteSetting[if current_user.privilege?('unrestricted')
@@ -113,7 +135,7 @@ class ApplicationController < ActionController::Base
       post.errors.add :base, edit_limit_msg
       AuditLog.rate_limit_log(event_type: 'suggested_edits', related: post, user: current_user,
                               comment: "limit: #{max_edits}")
-      render :edit, status: 400
+      render :edit, status: :bad_request
       return true
     end
     false
@@ -184,8 +206,8 @@ class ApplicationController < ActionController::Base
 
     Rails.logger.info "  Host #{host_name}, community ##{RequestContext.community_id} " \
                       "(#{RequestContext.community&.name})"
-    unless RequestContext.community.present?
-      render status: 422, plain: "No community record matching Host='#{host_name}'"
+    if RequestContext.community.blank?
+      render status: :unprocessable_entity, plain: "No community record matching Host='#{host_name}'"
       return false
     end
 
@@ -210,7 +232,7 @@ class ApplicationController < ActionController::Base
     end
     @hot_questions = Rails.cache.fetch("#{RequestContext.community_id}/hot_questions", expires_in: 4.hours) do
       Rack::MiniProfiler.step 'hot_questions: cache miss' do
-        Post.undeleted.where(last_activity: (Rails.env.development? ? 365 : 7).days.ago..Time.now)
+        Post.undeleted.where(last_activity: (Rails.env.development? ? 365 : 7).days.ago..Time.zone.now)
             .where(post_type_id: [Question.post_type_id, Article.post_type_id])
             .joins(:category).where(categories: { use_for_hot_posts: true })
             .where('score >= ?', SiteSetting['HotPostsScoreThreshold'])
diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb
deleted file mode 100644
index 21debf8bbf969c23acfe8368eef4b02c14ea59ad..0000000000000000000000000000000000000000
--- a/app/controllers/articles_controller.rb
+++ /dev/null
@@ -1,157 +0,0 @@
-class ArticlesController < ApplicationController
-  before_action :set_article
-  before_action :check_article
-  before_action :check_if_article_locked, only: [:edit, :update, :destroy, :undelete, :close, :reopen]
-
-  def show
-    if @article.deleted?
-      check_your_privilege('flag_curate', @article) # || return
-    end
-  end
-
-  def share
-    redirect_to article_path(params[:id])
-  end
-
-  def edit; end
-
-  def update
-    can_post_in_category = @article.category.present? &&
-                           (@article.category.min_trust_level || -1) <= current_user&.trust_level
-    unless current_user&.has_post_privilege?('edit_posts', @article) && can_post_in_category
-      return update_as_suggested_edit
-    end
-
-    tags_cache = params[:article][:tags_cache]&.reject { |e| e.to_s.empty? }
-    after_tags = Tag.where(tag_set_id: @article.category.tag_set_id, name: tags_cache)
-
-    if @article.tags == after_tags && @article.body_markdown == params[:article][:body_markdown] &&
-       @article.title == params[:article][:title]
-      flash[:danger] = "No changes were saved because you didn't edit the post."
-      return redirect_to article_path(@article)
-    end
-
-    body_rendered = helpers.post_markdown(:article, :body_markdown)
-    before = { body: @article.body_markdown, title: @article.title, tags: @article.tags }
-    if @article.update(article_params.merge(tags_cache: tags_cache, body: body_rendered,
-                                            last_activity: DateTime.now, last_activity_by: current_user,
-                                            last_edited_at: DateTime.now, last_edited_by: current_user))
-      PostHistory.post_edited(@article, current_user, before: before[:body],
-                              after: params[:article][:body_markdown], comment: params[:edit_comment],
-                              before_title: before[:title], after_title: params[:article][:title],
-                              before_tags: before[:tags], after_tags: after_tags)
-      redirect_to share_article_path(@article)
-    else
-      render :edit
-    end
-  end
-
-  def update_as_suggested_edit
-    return if check_edits_limit! @article
-
-    body_rendered = helpers.post_markdown(:article, :body_markdown)
-    new_tags_cache = params[:article][:tags_cache]&.reject(&:empty?)
-
-    body_markdown = if params[:article][:body_markdown] != @article.body_markdown
-                      params[:article][:body_markdown]
-                    end
-
-    if @article.tags_cache == new_tags_cache && @article.body_markdown == params[:article][:body_markdown] &&
-       @article.title == params[:article][:title]
-      flash[:danger] = "No changes were saved because you didn't edit the post."
-      return redirect_to article_path(@article)
-    end
-
-    updates = {
-      post: @article,
-      user: current_user,
-      community: @article.community,
-      body: body_rendered,
-      title: params[:article][:title] == @article.title ? nil : params[:article][:title],
-      tags_cache: new_tags_cache == @article.tags_cache ? @article.tags_cache : new_tags_cache,
-      body_markdown: body_markdown,
-      comment: params[:edit_comment],
-      active: true, accepted: false,
-      decided_at: nil, decided_by: nil,
-      rejected_comment: nil
-    }
-
-    @edit = SuggestedEdit.new(updates)
-    if @edit.save
-      @article.user.create_notification("Edit suggested on your post #{@article.title.truncate(50)}",
-                                        article_url(@article))
-      redirect_to share_article_path(@article)
-    else
-      @article.errors = @edit.errors
-      render :edit
-    end
-  end
-
-  def destroy
-    unless check_your_privilege('flag_curate', @article, false)
-      flash[:danger] = helpers.ability_err_msg(:flag_curate, 'delete this article')
-      redirect_to article_path(@article) && return
-    end
-
-    if @article.deleted
-      flash[:danger] = "Can't delete a deleted post."
-      redirect_to article_path(@article) && return
-    end
-
-    if @article.update(deleted: true, deleted_at: DateTime.now, deleted_by: current_user,
-                       last_activity: DateTime.now, last_activity_by: current_user)
-      PostHistory.post_deleted(@article, current_user)
-    else
-      flash[:danger] = "Can't delete this post right now. Try again later."
-    end
-    redirect_to article_path(@article)
-  end
-
-  def undelete
-    unless check_your_privilege('flag_curate', @article, false)
-      flash[:danger] = helpers.ability_err_msg(:flag_curate, 'undelete this article')
-      redirect_to article_path(@article) && return
-    end
-
-    unless @article.deleted
-      flash[:danger] = "Can't undelete an undeleted post."
-      redirect_to article_path(@article) && return
-    end
-
-    if @article.deleted_by.is_moderator && !current_user.is_moderator
-      flash[:danger] = 'You cannot undelete this post deleted by a moderator.'
-      redirect_to(article_path(@article)) && return
-    end
-
-    if @article.update(deleted: false, deleted_at: nil, deleted_by: nil,
-                       last_activity: DateTime.now, last_activity_by: current_user)
-      PostHistory.post_undeleted(@article, current_user)
-    else
-      flash[:danger] = "Can't undelete this article right now. Try again later."
-    end
-    redirect_to article_path(@article)
-  end
-
-  private
-
-  def set_article
-    @article = Article.find params[:id]
-    if @article.deleted && !current_user&.has_post_privilege?('flag_curate', @article)
-      not_found
-    end
-  end
-
-  def check_article
-    unless @article.post_type_id == Article.post_type_id
-      not_found
-    end
-  end
-
-  def article_params
-    params.require(:article).permit(:body_markdown, :title, :tags_cache)
-  end
-
-  def check_if_article_locked
-    check_if_locked(@article)
-  end
-end
diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb
index 6d82fb3211794e75bdbcb6b5dcda7b32afc79614..84e61e08af8ae2c03ae2295854fea5ea31aaa42f 100644
--- a/app/controllers/categories_controller.rb
+++ b/app/controllers/categories_controller.rb
@@ -1,8 +1,8 @@
 class CategoriesController < ApplicationController
-  before_action :authenticate_user!, except: [:index, :show, :homepage, :rss_feed]
-  before_action :verify_admin, except: [:index, :show, :homepage, :rss_feed]
+  before_action :authenticate_user!, except: [:index, :show, :homepage, :rss_feed, :post_types]
+  before_action :verify_admin, except: [:index, :show, :homepage, :rss_feed, :post_types]
   before_action :set_category, except: [:index, :homepage, :new, :create]
-  before_action :verify_view_access, except: [:index, :homepage, :new, :create]
+  before_action :verify_view_access, except: [:index, :homepage, :new, :create, :post_types]
 
   def index
     @categories = Category.all.order(:sequence, :name)
@@ -83,6 +83,13 @@ class CategoriesController < ApplicationController
     set_list_posts
   end
 
+  def post_types
+    @post_types = @category.post_types.where(is_top_level: true)
+    if @post_types.count == 1
+      redirect_to new_category_post_path(post_type: @post_types.first, category: @category)
+    end
+  end
+
   private
 
   def set_category
@@ -116,7 +123,7 @@ class CategoriesController < ApplicationController
   end
 
   def update_last_visit(category)
-    return unless current_user.present?
+    return if current_user.blank?
 
     key = "#{RequestContext.community_id}/#{current_user.id}/#{category.id}/last_visit"
     RequestContext.redis.set key, DateTime.now.to_s
diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb
index 80813a195811daebb06b99c9be834b39b3f869cb..c860421acbe25663985e10f22d26e9239392a46e 100644
--- a/app/controllers/comments_controller.rb
+++ b/app/controllers/comments_controller.rb
@@ -5,16 +5,17 @@ class CommentsController < ApplicationController
   before_action :check_privilege, only: [:update, :destroy, :undelete]
   before_action :check_if_target_post_locked, only: [:create]
   before_action :check_if_parent_post_locked, only: [:update, :destroy]
+
   def create
     @post = Post.find(params[:comment][:post_id])
     if @post.comments_disabled && !current_user.is_moderator && !current_user.is_admin
-      render json: { status: 'failed', message: 'Comments have been disabled on this post.' }, status: 403
+      render json: { status: 'failed', message: 'Comments have been disabled on this post.' }, status: :forbidden
       return
     end
 
     @comment = Comment.new comment_params.merge(user: current_user)
 
-    recent_comments = Comment.where(created_at: 24.hours.ago..Time.now, user: current_user).where \
+    recent_comments = Comment.where(created_at: 24.hours.ago..Time.zone.now, user: current_user).where \
                              .not(post: Post.includes(:parent).where(parents_posts: { user_id: current_user.id })) \
                              .where.not(post: Post.where(user_id: current_user.id)).count
     max_comments_per_day = SiteSetting[current_user.privilege?('unrestricted') ? 'RL_Comments' : 'RL_NewUserComments']
@@ -33,7 +34,7 @@ class CommentsController < ApplicationController
       AuditLog.rate_limit_log(event_type: 'comment', related: @comment, user: current_user,
                             comment: "limit: #{max_comments_per_day}\n\comment:\n#{@comment.attributes_print}")
 
-      render json: { status: 'failed', message: comment_limit_msg }, status: 403
+      render json: { status: 'failed', message: comment_limit_msg }, status: :forbidden
       return
     end
 
@@ -53,7 +54,8 @@ class CommentsController < ApplicationController
       render json: { status: 'success',
                      comment: render_to_string(partial: 'comments/comment', locals: { comment: @comment }) }
     else
-      render json: { status: 'failed', message: @comment.errors.full_messages.join(', ') }, status: 500
+      render json: { status: 'failed', message: @comment.errors.full_messages.join(', ') },
+             status: :internal_server_error
     end
   end
 
@@ -68,7 +70,8 @@ class CommentsController < ApplicationController
                      comment: render_to_string(partial: 'comments/comment', locals: { comment: @comment }) }
     else
       render json: { status: 'failed',
-                     message: "Comment failed to save (#{@comment.errors.full_messages.join(', ')})" }, status: 500
+                     message: "Comment failed to save (#{@comment.errors.full_messages.join(', ')})" },
+             status: :internal_server_error
     end
   end
 
@@ -80,7 +83,7 @@ class CommentsController < ApplicationController
       end
       render json: { status: 'success' }
     else
-      render json: { status: 'failed' }, status: 500
+      render json: { status: 'failed' }, status: :internal_server_error
     end
   end
 
@@ -92,7 +95,7 @@ class CommentsController < ApplicationController
       end
       render json: { status: 'success' }
     else
-      render json: { status: 'failed' }, status: 500
+      render json: { status: 'failed' }, status: :internal_server_error
     end
   end
 
@@ -127,17 +130,15 @@ class CommentsController < ApplicationController
 
   def check_privilege
     unless current_user.is_moderator || current_user.is_admin || current_user == @comment.user
-      render template: 'errors/forbidden', status: 403
+      render template: 'errors/forbidden', status: :forbidden
     end
   end
 
   def comment_link(comment)
-    if comment.post.question?
-      question_url(comment.post, anchor: "comment-#{comment.id}")
-    elsif comment.post.article?
-      article_url(comment.post, anchor: "comment-#{comment.id}")
+    if comment.post.parent_id.present?
+      post_url(comment.post.parent_id, anchor: "comment-#{comment.id}")
     else
-      question_url(comment.post.parent, anchor: "comment-#{comment.id}")
+      post_url(comment.post, anchor: "comment-#{comment.id}")
     end
   end
 
diff --git a/app/controllers/flags_controller.rb b/app/controllers/flags_controller.rb
index dc562faac620bc7047a2b4595bf9e43dc63260e8..b07e521d1b490076e1c748a34239474f214fd719 100644
--- a/app/controllers/flags_controller.rb
+++ b/app/controllers/flags_controller.rb
@@ -9,7 +9,7 @@ class FlagsController < ApplicationController
              PostFlagType.find params[:flag_type]
            end
 
-    recent_flags = Flag.where(created_at: 24.hours.ago..Time.now, user: current_user).count
+    recent_flags = Flag.where(created_at: 24.hours.ago..Time.zone.now, user: current_user).count
     max_flags_per_day = SiteSetting[current_user.privilege?('unrestricted') ? 'RL_Flags' : 'RL_NewUserFlags']
 
     if recent_flags >= max_flags_per_day
@@ -20,15 +20,15 @@ class FlagsController < ApplicationController
       AuditLog.rate_limit_log(event_type: 'flag', related: Post.find(params[:post_id]), user: current_user,
                         comment: "limit: #{max_flags_per_day}\n\ntype:#{type}\ncomment:\n#{params[:reason].to_i}")
 
-      render json: { status: 'failed', message: flag_limit_msg }, status: 403
+      render json: { status: 'failed', message: flag_limit_msg }, status: :forbidden
       return
     end
 
     @flag = Flag.new(post_flag_type: type, reason: params[:reason], post_id: params[:post_id], user: current_user)
     if @flag.save
-      render json: { status: 'success' }, status: 201
+      render json: { status: 'success' }, status: :created
     else
-      render json: { status: 'failed', message: 'Flag failed to save.' }, status: 500
+      render json: { status: 'failed', message: 'Flag failed to save.' }, status: :internal_server_error
     end
   end
 
@@ -57,7 +57,7 @@ class FlagsController < ApplicationController
       AbilityQueue.add(@flag.user, "Flag Handled ##{@flag.id}")
       render json: { status: 'success' }
     else
-      render json: { status: 'failed', message: 'Failed to save new status.' }, status: 500
+      render json: { status: 'failed', message: 'Failed to save new status.' }, status: :internal_server_error
     end
   end
 
diff --git a/app/controllers/licenses_controller.rb b/app/controllers/licenses_controller.rb
index 353841dd5c17ad5a329caa7a1548b7e16b18d9a1..fa25284e67e7d655827a5a91dfad3c63cab8d460 100644
--- a/app/controllers/licenses_controller.rb
+++ b/app/controllers/licenses_controller.rb
@@ -21,7 +21,7 @@ class LicensesController < ApplicationController
                            comment: "<<License #{@license.attributes_print}>>")
       redirect_to licenses_path
     else
-      render :new, status: 400
+      render :new, status: :bad_request
     end
   end
 
@@ -37,7 +37,7 @@ class LicensesController < ApplicationController
                            comment: "from <<License #{before}>>\nto <<License #{@license.attributes_print}>>")
       redirect_to licenses_path
     else
-      render :edit, status: 400
+      render :edit, status: :bad_request
     end
   end
 
diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb
index bfa32e76493254246aa7036639bb0b714974a71d..02477f56a6d9e46a32a5d6f8d9677349a3d03ec0 100644
--- a/app/controllers/notifications_controller.rb
+++ b/app/controllers/notifications_controller.rb
@@ -16,8 +16,8 @@ class NotificationsController < ApplicationController
 
     unless @notification.user == current_user
       respond_to do |format|
-        format.html { render template: 'errors/forbidden', status: 403 }
-        format.json { render json: nil, status: 403 }
+        format.html { render template: 'errors/forbidden', status: :forbidden }
+        format.json { render json: nil, status: :forbidden }
       end
       return
     end
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index ee0a0d1dec9f788166e7e3392550a22040bdf4f7..d2aea372eb459ee27e5aac7564427b511381e229 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -1,106 +1,319 @@
+# rubocop:disable Metrics/ClassLength
 class PostsController < ApplicationController
-  before_action :authenticate_user!, except: [:document, :share_q, :share_a, :help_center]
-  before_action :set_post, only: [:edit_help, :update_help, :toggle_comments, :feature, :lock, :unlock]
-  before_action :set_scoped_post, only: [:change_category]
-  before_action :check_permissions, only: [:edit_help, :update_help]
-  before_action :verify_moderator, only: [:new_help, :create_help, :toggle_comments]
+  before_action :authenticate_user!, except: [:document, :help_center, :show]
+  before_action :set_post, only: [:toggle_comments, :feature, :lock, :unlock]
+  before_action :set_scoped_post, only: [:change_category, :show, :edit, :update, :close, :reopen, :delete, :restore]
+  before_action :verify_moderator, only: [:toggle_comments]
+  before_action :edit_checks, only: [:edit, :update]
+  before_action :unless_locked, only: [:edit, :update, :close, :reopen, :delete, :restore]
 
   def new
-    @category = Category.find(params[:category_id])
-    @post = Post.new(category: @category, post_type_id: params[:post_type_id])
-    if @category.min_trust_level.present? && @category.min_trust_level > current_user.trust_level
-      flash[:danger] = "You don't have a high enough trust level to post in the #{@category.name} category."
+    @post_type = PostType.find(params[:post_type])
+    @category = params[:category].present? ? Category.find(params[:category]) : nil
+    @parent = Post.where(id: params[:parent]).first
+    @post = Post.new(category: @category, post_type: @post_type, parent: @parent)
+
+    if @post_type.has_parent? && @parent.nil?
+      flash[:danger] = helpers.i18ns('posts.type_requires_parent', type: @post_type.name)
       redirect_back fallback_location: root_path
+      return
+    end
+
+    if @post_type.has_category? && @category.nil? && @parent.nil?
+      flash[:danger] = helpers.i18ns('posts.type_requires_category', type: @post_type.name)
+      redirect_back fallback_location: root_path
+      return
+    end
+
+    if ['HelpDoc', 'PolicyDoc'].include?(@post_type.name)
+      check_permissions
+      # return # uncomment if you add more code after this
     end
   end
 
   def create
-    @category = Category.find(params[:category_id])
-    @post = Post.new(post_params.merge(category: @category, user: current_user,
-                                       post_type_id: params[:post][:post_type_id] || params[:post_type_id],
-                                       body: helpers.post_markdown(:post, :body_markdown)))
-
-    if @category.min_trust_level.present? && @category.min_trust_level > current_user.trust_level
-      @post.errors.add(:base, "You don't have a high enough trust level to post in the #{@category.name} category.")
-      render :new, status: 403
+    @post_type = PostType.find(params[:post][:post_type_id])
+    @parent = Post.where(id: params[:parent]).first
+    @category = if @post_type.has_category
+                  if params[:post][:category_id].present?
+                    Category.find(params[:post][:category_id])
+                  elsif @parent.present?
+                    @parent.category
+                  end
+                end || nil
+    @post = Post.new(post_params.merge(user: current_user, body: helpers.post_markdown(:post, :body_markdown),
+                                       category: @category, post_type: @post_type, parent: @parent))
+
+    if @post_type.has_parent? && @parent.nil?
+      flash[:danger] = helpers.i18ns('posts.type_requires_parent', type: @post_type.name)
+      redirect_back fallback_location: root_path
+      return
+    end
+
+    if @post_type.has_category? && @category.nil? && @parent.nil?
+      flash[:danger] = helpers.i18ns('posts.type_requires_category', type: @post_type.name)
+      redirect_back fallback_location: root_path
       return
     end
 
-    recent_top_level_posts = Post.where(created_at: 24.hours.ago..Time.now, user: current_user) \
-                                 .where(post_type_id: top_level_post_types).count
+    if @category.present? && @category.min_trust_level.present? && @category.min_trust_level > current_user.trust_level
+      @post.errors.add(:base, helpers.i18ns('posts.category_low_trust_level', category: @category.name))
+      render :new, status: :forbidden
+      return
+    end
 
-    max_posts = SiteSetting[current_user.privilege?('unrestricted') ? 'RL_TopLevelPosts' : 'RL_NewUserTopLevelPosts']
-    post_limit_msg = if current_user.privilege? 'unrestricted'
-                       "You may only post #{max_posts} top-level posts per day."
-                     else
-                       "You may only post #{max_posts} top-level posts (questions, articles) per day. " \
-                       'Once you have some well-received posts, that limit will increase.'
-                     end
+    if ['HelpDoc', 'PolicyDoc'].include?(@post_type.name) && !check_permissions
+      return
+    end
 
-    if recent_top_level_posts >= max_posts
-      @post.errors.add :base, post_limit_msg
-      AuditLog.rate_limit_log(event_type: 'top_level_post', related: @category, user: current_user,
+    level_name = @post_type.is_top_level? ? 'TopLevel' : 'SecondLevel'
+    level_type_ids = @post_type.is_top_level? ? top_level_post_types : second_level_post_types
+    recent_level_posts = Post.where(created_at: 24.hours.ago..Time.zone.now, user: current_user)
+                             .where(post_type_id: level_type_ids).count
+    setting_name = current_user.privilege?('unrestricted') ? "RL_#{level_name}Posts" : "RL_NewUser#{level_name}Posts"
+    max_posts = SiteSetting[setting_name]
+    limit_msg = if current_user.privilege?('unrestricted')
+                  helpers.i18ns('rate_limit.posts', count: max_posts, level: level_name.underscore.humanize.downcase)
+                else
+                  helpers.i18ns('rate_limit.new_user_posts',
+                                count: max_posts, level: level_name.underscore.humanize.downcase)
+                end
+
+    if recent_level_posts >= max_posts
+      @post.errors.add :base, limit_msg
+      AuditLog.rate_limit_log(event_type: "#{level_name.underscore}_post", related: @category, user: current_user,
                               comment: "limit: #{max_posts}\n\npost:\n#{@post.attributes_print}")
-      render :new, status: 400
+      render :new, status: :forbidden
       return
     end
 
     if @post.save
       redirect_to helpers.generic_show_link(@post)
     else
-      render :new, status: 400
+      render :new, status: :bad_request
     end
   end
 
-  def new_help
-    @post = Post.new
+  def show
+    if @post.parent_id.present?
+      return redirect_to post_path(@post.parent_id)
+    end
+
+    if @post.deleted? && !current_user&.has_post_privilege?('flag_curate', @post)
+      return not_found
+    end
+
+    @children = if current_user&.privilege?('flag_curate')
+                  Post.where(parent_id: @post.id)
+                else
+                  Post.where(parent_id: @post.id).undeleted
+                      .or(Post.where(parent_id: @post.id, user_id: current_user&.id))
+                end.includes(:votes, :user, :comments, :license, :post_type)
+                .user_sort({ term: params[:sort], default: Arel.sql('deleted ASC, score DESC, RAND()') },
+                           score: Arel.sql('deleted ASC, score DESC, RAND()'), age: :created_at)
+                .paginate(page: params[:page], per_page: 20)
   end
 
-  def create_help
-    setting_regex = /\${(?<setting_name>[^}]+)}/
-    params[:post][:body_markdown] = params[:post][:body_markdown].gsub(setting_regex) do |_match|
-      setting_name = $LAST_MATCH_INFO&.send(:[], :setting_name)
-      if setting_name.nil?
-        ''
+  def edit; end
+
+  def update
+    before = { body: @post.body_markdown, title: @post.title, tags: @post.tags }
+    after_tags = if @post_type.has_category?
+                   Tag.where(tag_set_id: @post.category.tag_set_id, name: params[:post][:tags_cache])
+                 end
+    body_rendered = helpers.post_markdown(:post, :body_markdown)
+    new_tags_cache = params[:post][:tags_cache]&.reject(&:empty?)
+
+    if edit_post_params.to_h.all? { |k, v| @post.send(k) == v }
+      flash[:danger] = "No changes were saved because you didn't edit the post."
+      return redirect_to post_path(@post)
+    end
+
+    if current_user.privilege?('edit_posts') || current_user.is_moderator || current_user == @post.user
+      if @post.update(edit_post_params.merge(body: body_rendered,
+                                             last_edited_at: DateTime.now, last_edited_by: current_user,
+                                             last_activity: DateTime.now, last_activity_by: current_user))
+        PostHistory.post_edited(@post, current_user, before: before[:body],
+                                after: @post.body_markdown, comment: params[:edit_comment],
+                                before_title: before[:title], after_title: @post.title,
+                                before_tags: before[:tags], after_tags: after_tags)
+        redirect_to post_path(@post)
+      else
+        render :edit, status: :bad_request
+      end
+    else
+      new_user = !current_user.privilege?('unrestricted')
+      rate_limit = SiteSetting["RL_#{new_user ? 'NewUser' : ''}SuggestedEdits"]
+      recent_edits = SuggestedEdit.where(user: current_user, active: true).where('created_at > ?', 24.hours.ago).count
+      if recent_edits >= rate_limit
+        key = new_user ? 'rate_limit.new_user_suggested_edits' : 'rate_limit.suggested_edits'
+        msg = helpers.i18ns key, count: rate_limit
+        @post.errors.add :base, msg
+        render :edit, status: :forbidden
       else
-        SiteSetting[setting_name] || '(No such setting)'
+        data = {
+          post: @post,
+          user: current_user,
+          body: body_rendered == @post.body ? nil : body_rendered,
+          title: params[:post][:title] == @post.title ? nil : params[:post][:title],
+          tags_cache: new_tags_cache == @post.tags_cache ? @post.tags_cache : new_tags_cache,
+          body_markdown: params[:post][:body_markdown] == @post.body_markdown ? nil : params[:post][:body_markdown],
+          comment: params[:edit_comment],
+          active: true, accepted: false
+        }
+        edit = SuggestedEdit.new(data)
+        if edit.save
+          message = "Edit suggested on your #{@post_type.name.underscore.humanize.downcase}"
+          if @post_type.has_parent
+            message += " on '#{@post.parent.title}'"
+          end
+          @post.user.create_notification message, suggested_edit_path(edit)
+          redirect_to post_path(@post)
+        else
+          @post.errors = edit.errors
+          render :edit, status: :bad_request
+        end
       end
     end
-    @post = Post.new(new_post_params.merge(body: helpers.post_markdown(:post, :body_markdown),
-                                           user: User.find(-1)))
+  end
 
-    if @post.policy_doc? && !current_user&.is_admin
-      @post.errors.add(:base, 'You must be an administrator to create a policy document.')
-      render :new_help, status: 403
+  def close
+    unless check_your_privilege('flag_close', nil, false)
+      render json: { status: 'failed', message: helpers.ability_err_msg(:flag_close, 'close this post') },
+             status: :forbidden
       return
     end
 
-    if @post.save
-      redirect_to policy_path(slug: @post.doc_slug)
+    if @post.closed
+      render json: { status: 'failed', message: 'Cannot close a closed post.' }, status: :bad_request
+      return
+    end
+
+    reason = CloseReason.find_by id: params[:reason_id]
+    if reason.nil?
+      render json: { status: 'failed', message: 'Close reason not found.' }, status: :not_found
+      return
+    end
+
+    if reason.requires_other_post
+      other = Post.find_by(id: params[:other_post])
+      if other.nil? || !top_level_post_types.include?(other.post_type_id)
+        render json: { status: 'failed', message: 'Invalid input for other post.' }, status: :bad_request
+        return
+      end
+
+      duplicate_of = Question.find(params[:other_post])
+    else
+      duplicate_of = nil
+    end
+
+    if @post.update(closed: true, closed_by: current_user, closed_at: DateTime.now, last_activity: DateTime.now,
+                    last_activity_by: current_user, close_reason: reason, duplicate_post: duplicate_of)
+      PostHistory.question_closed(@post, current_user)
+      render json: { status: 'success' }
     else
-      render :new_help, status: 500
+      render json: { status: 'failed', message: "Can't close this question right now. Try again later.",
+                     errors: @post.errors.full_messages }
     end
   end
 
-  def edit_help; end
+  def reopen
+    unless check_your_privilege('flag_close', nil, false)
+      flash[:danger] = helpers.ability_err_msg(:flag_close, 'reopen this post')
+      redirect_to post_path(@post)
+      return
+    end
 
-  def update_help
-    setting_regex = /\${(?<setting_name>[^}]+)}/
-    params[:post][:body_markdown] = params[:post][:body_markdown].gsub(setting_regex) do |_match|
-      setting_name = $LAST_MATCH_INFO&.send(:[], :setting_name)
-      if setting_name.nil?
-        ''
-      else
-        SiteSetting[setting_name] || '(No such setting)'
+    unless @post.closed
+      flash[:danger] = 'Cannot reopen an open post.'
+      redirect_to post_path(@post)
+      return
+    end
+
+    if @post.update(closed: false, closed_by: current_user, closed_at: Time.zone.now,
+                    last_activity: DateTime.now, last_activity_by: current_user,
+                    close_reason: nil, duplicate_post: nil)
+      PostHistory.question_reopened(@post, current_user)
+    else
+      flash[:danger] = "Can't reopen this post right now. Try again later."
+    end
+    redirect_to post_path(@post)
+  end
+
+  def delete
+    unless check_your_privilege('flag_curate', @post, false)
+      flash[:danger] = helpers.ability_err_msg(:flag_curate, 'delete this post')
+      redirect_to post_path(@post)
+      return
+    end
+
+    if @post.children.any? { |a| a.score >= 0.5 }
+      flash[:danger] = 'This post cannot be deleted because it has responses.'
+      redirect_to post_path(@post)
+      return
+    end
+
+    if @post.deleted
+      flash[:danger] = "Can't delete a deleted post."
+      redirect_to post_path(@post)
+      return
+    end
+
+    if @post.update(deleted: true, deleted_at: DateTime.now, deleted_by: current_user,
+                    last_activity: DateTime.now, last_activity_by: current_user)
+      PostHistory.post_deleted(@post, current_user)
+      if @post.children.any?
+        @post.children.update_all(deleted: true, deleted_at: DateTime.now, deleted_by_id: current_user.id,
+                                  last_activity: DateTime.now, last_activity_by_id: current_user.id)
+        histories = @post.children.map do |c|
+          { post_history_type: PostHistoryType.find_by(name: 'post_deleted'), user: current_user, post: c,
+            community: RequestContext.community }
+        end
+        PostHistory.create(histories)
       end
+    else
+      flash[:danger] = "Can't delete this post right now. Try again later."
+    end
+
+    redirect_to post_path(@post)
+  end
+
+  def restore
+    unless check_your_privilege('flag_curate', @post, false)
+      flash[:danger] = helpers.ability_err_msg(:flag_curate, 'restore this post')
+      redirect_to post_path(@post)
+      return
+    end
+
+    unless @post.deleted
+      flash[:danger] = "Can't restore an undeleted post."
+      redirect_to post_path(@post)
+      return
+    end
+
+    if @post.deleted_by.is_moderator && !current_user.is_moderator
+      flash[:danger] = 'You cannot restore this post deleted by a moderator.'
+      redirect_to post_path(@post)
+      return
     end
-    PostHistory.post_edited(@post, current_user, before: @post.body_markdown, after: params[:post][:body_markdown])
-    if @post.update(help_post_params.merge(body: helpers.post_markdown(:post, :body_markdown),
-                                           last_activity: DateTime.now, last_activity_by: current_user))
-      redirect_to policy_path(slug: @post.doc_slug)
+
+    deleted_at = @post.deleted_at
+    if @post.update(deleted: false, deleted_at: nil, deleted_by: nil,
+                    last_activity: DateTime.now, last_activity_by: current_user)
+      PostHistory.post_undeleted(@post, current_user)
+      restore_children = @post.children.where('deleted_at >= ?', deleted_at)
+      restore_children.update_all(deleted: true, deleted_at: DateTime.now, deleted_by_id: current_user.id,
+                                 last_activity: DateTime.now, last_activity_by_id: current_user.id)
+      histories = restore_children.map do |c|
+        { post_history_type: PostHistoryType.find_by(name: 'post_undeleted'), user: current_user, post: c,
+          community: RequestContext.community }
+      end
+      PostHistory.create(histories)
     else
-      render :edit_help, status: 500
+      flash[:danger] = "Can't restore this post right now. Try again later."
     end
+
+    redirect_to post_path(@post)
   end
 
   def document
@@ -119,7 +332,7 @@ class PostsController < ApplicationController
     content_types = ActiveStorage::Variant::WEB_IMAGE_CONTENT_TYPES
     extensions = content_types.map { |ct| ct.gsub('image/', '') }
     unless helpers.valid_image?(params[:file])
-      render json: { error: "Images must be one of #{extensions.join(', ')}" }, status: 400
+      render json: { error: "Images must be one of #{extensions.join(', ')}" }, status: :bad_request
       return
     end
     @blob = ActiveStorage::Blob.create_after_upload!(io: params[:file], filename: params[:file].original_filename,
@@ -127,14 +340,6 @@ class PostsController < ApplicationController
     render json: { link: uploaded_url(@blob.key) }
   end
 
-  def share_q
-    redirect_to question_path(id: params[:id])
-  end
-
-  def share_a
-    redirect_to question_path(id: params[:qid], anchor: "answer-#{params[:id]}")
-  end
-
   def help_center
     @posts = Post.where(post_type_id: [PolicyDoc.post_type_id, HelpDoc.post_type_id])
                  .or(Post.unscoped.where(post_type_id: [PolicyDoc.post_type_id, HelpDoc.post_type_id],
@@ -142,19 +347,19 @@ class PostsController < ApplicationController
                  .where(Arel.sql("posts.help_category IS NULL OR posts.help_category != '$Disabled'"))
                  .order(:help_ordering, :title)
                  .group_by(&:post_type_id)
-                 .transform_values { |posts| posts.group_by { |p| p.help_category.present? ? p.help_category : nil } }
+                 .transform_values { |posts| posts.group_by { |p| p.help_category.presence } }
   end
 
   def change_category
     @target = Category.find params[:target_id]
     unless helpers.can_change_category(current_user, @target)
-      render json: { success: false, errors: ["You don't have permission to make that change."] }, status: 403
+      render json: { success: false, errors: ["You don't have permission to make that change."] }, status: :forbidden
       return
     end
 
     unless @target.post_type_ids.include? @post.post_type_id
       render json: { success: false, errors: ["This post type is not allowed in the #{@target.name} category."] },
-             status: 409
+             status: :conflict
       return
     end
 
@@ -172,15 +377,11 @@ class PostsController < ApplicationController
   end
 
   def toggle_comments
-    @post.comments_disabled = !@post.comments_disabled
-    @post.save
+    @post.update(comments_disabled: !@post.comments_disabled)
     if @post.comments_disabled && params[:delete_all_comments]
-      @post.comments.undeleted.map do |c|
-        c.deleted = true
-        c.save
-      end
+      @post.comments.update_all(deleted: true)
     end
-    render json: { success: true }
+    render json: { status: 'success', success: true }
   end
 
   def lock
@@ -201,33 +402,38 @@ class PostsController < ApplicationController
 
     @post.update locked: true, locked_by: current_user,
                  locked_at: DateTime.now, locked_until: end_date
-    render json: { success: true }
+    render json: { status: 'success', success: true }
   end
 
   def unlock
-    return not_found unless current_user.privilege? 'flag_curate'
-    return not_found unless @post.locked?
-    return not_found if @post.locked_until.nil? && !current_user.is_moderator
+    return not_found(errors: ['no_privilege']) unless current_user.privilege? 'flag_curate'
+    return not_found(errors: ['not_locked']) unless @post.locked?
+    if @post.locked_by.is_moderator && !current_user.is_moderator
+      return not_found(errors: ['locked_by_mod'])
+    end
 
     @post.update locked: false, locked_by: nil,
                  locked_at: nil, locked_until: nil
-    render json: { success: true }
+    render json: { status: 'success', success: true }
   end
 
   def feature
+    return not_found(errors: ['no_privilege']) unless current_user.is_moderator
+
     data = {
       label: @post.parent.nil? ? @post.title : @post.parent.title,
       link: helpers.generic_show_link(@post),
       post: @post,
-      active: true
+      active: true,
+      community: RequestContext.community
     }
     @link = PinnedLink.create data
 
     attr = @link.attributes_print
     AuditLog.moderator_audit(event_type: 'pinned_link_create', related: @link, user: current_user,
-                            comment: "<<PinnedLink #{attr}>>\n(using moderator tools on post)")
+                             comment: "<<PinnedLink #{attr}>>\n(using moderator tools on post)")
     flash[:success] = 'Post has been featured. Due to caching, it may take some time until the changes apply.'
-    render json: { success: true }
+    render json: { status: 'success', success: true }
   end
 
   def save_draft
@@ -237,28 +443,32 @@ class PostsController < ApplicationController
     RequestContext.redis.set saved_at, DateTime.now.iso8601
     RequestContext.redis.expire key, 86_400 * 7
     RequestContext.redis.expire saved_at, 86_400 * 7
-    render json: { success: true, key: key }
+    render json: { status: 'success', success: true, key: key }
   end
 
   def delete_draft
     key = "saved_post.#{current_user.id}.#{params[:path]}"
     saved_at = "saved_post_at.#{current_user.id}.#{params[:path]}"
     RequestContext.redis.del key, saved_at
-    render json: { success: true }
+    render json: { status: 'success', success: true }
   end
 
   private
 
-  def new_post_params
-    params.require(:post).permit(:post_type_id, :title, :doc_slug, :help_category, :body_markdown, :help_ordering)
+  def permitted
+    [:post_type_id, :category_id, :parent_id, :title, :body_markdown, :license_id,
+     :doc_slug, :help_category, :help_ordering]
   end
 
-  def help_post_params
-    params.require(:post).permit(:title, :help_category, :body_markdown, :help_ordering)
+  def post_params
+    p = params.require(:post).permit(*permitted, tags_cache: [])
+    p[:tags_cache] = p[:tags_cache]&.reject { |t| t.empty? }
+    p
   end
 
-  def post_params
-    p = params.require(:post).permit(:title, :body_markdown, :post_type_id, :license_id, tags_cache: [])
+  def edit_post_params
+    p = params.require(:post).permit(*(permitted - [:license_id, :post_type_id, :category_id, :parent_id]),
+                                     tags_cache: [])
     p[:tags_cache] = p[:tags_cache]&.reject { |t| t.empty? }
     p
   end
@@ -280,4 +490,25 @@ class PostsController < ApplicationController
       not_found
     end
   end
+
+  def edit_checks
+    @category = @post.category
+    @parent = @post.parent
+    @post_type = @post.post_type
+
+    if @post_type.has_parent? && @parent.nil?
+      flash[:danger] = helpers.i18ns('posts.type_requires_parent', type: @post_type.name)
+      redirect_back fallback_location: root_path
+    end
+
+    if !@post_type.is_public_editable && !(@post.user == current_user || current_user.is_moderator)
+      flash[:danger] = helpers.i18ns('posts.not_public_editable')
+      redirect_back fallback_location: root_path
+    end
+  end
+
+  def unless_locked
+    check_if_locked(@post)
+  end
 end
+# rubocop:enable Metrics/ClassLength
diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb
index fd24ccf1c2d719cf99a995213aeddd5bd7a7ac4f..1b57bce8494fefe93112ac946b66ba7069e37e18 100644
--- a/app/controllers/questions_controller.rb
+++ b/app/controllers/questions_controller.rb
@@ -1,52 +1,6 @@
 # Web controller. Provides actions that relate to questions - this is essentially the standard set of resources, plus a
 # couple for the extra question lists (such as listing by tag).
 class QuestionsController < ApplicationController
-  before_action :authenticate_user!, only: [:new, :new_meta, :create, :edit, :update, :destroy, :undelete,
-                                            :close, :reopen]
-  before_action :set_question, only: [:show, :edit, :update, :destroy, :undelete, :close, :reopen]
-  before_action :check_if_question_locked, only: [:edit, :update, :destroy, :undelete, :close, :reopen]
-
-  def index
-    sort_params = { activity: :last_activity, age: :created_at, score: :score }
-    sort_param = sort_params[params[:sort]&.to_sym] || :last_activity
-    @questions = Question.list_includes.main.undeleted.order(sort_param => :desc)
-                         .paginate(page: params[:page], per_page: 25)
-  end
-
-  def meta
-    sort_params = { activity: :last_activity, age: :created_at, score: :score }
-    sort_param = sort_params[params[:sort]&.to_sym] || :last_activity
-    @questions = Question.list_includes.meta.undeleted.order(sort_param => :desc)
-                         .paginate(page: params[:page], per_page: 25)
-  end
-
-  def show
-    if @question.deleted?
-      check_your_privilege('flag_curate', @question) || return
-    end
-
-    @answers = if current_user&.privilege?('flag_curate')
-                 Answer.where(parent_id: @question.id)
-               else
-                 Answer.where(parent_id: @question.id).undeleted
-                       .or(Answer.where(parent_id: @question.id, user_id: current_user&.id))
-               end.user_sort({ term: params[:sort], default: Arel.sql('deleted ASC, score DESC, RAND()') },
-                             score: Arel.sql('deleted ASC, score DESC, RAND()'), age: :created_at)
-               .paginate(page: params[:page], per_page: 20)
-               .includes(:votes, :user, :comments, :license)
-
-    @close_reasons = CloseReason.active
-  end
-
-  def tagged
-    @tag = Tag.find_by name: params[:tag], tag_set_id: params[:tag_set]
-    if @tag.nil?
-      not_found
-      return
-    end
-    @questions = @tag.posts.list_includes.undeleted.order('updated_at DESC').paginate(page: params[:page], per_page: 50)
-  end
-
   def lottery
     ids = Rails.cache.fetch 'lottery_questions', expires_in: 24.hours do
       # noinspection RailsParamDefResolve
@@ -57,129 +11,6 @@ class QuestionsController < ApplicationController
     @questions = Question.list_includes.where(id: ids).paginate(page: params[:page], per_page: 25)
   end
 
-  def edit; end
-
-  def update
-    can_post_in_category = @question.category.present? &&
-                           (@question.category.min_trust_level || -1) <= current_user&.trust_level
-    unless current_user&.has_post_privilege?('edit_posts', @question) && can_post_in_category
-      return update_as_suggested_edit
-    end
-
-    tags_cache = params[:question][:tags_cache]&.reject { |e| e.to_s.empty? }
-    after_tags = Tag.where(tag_set_id: @question.category.tag_set_id, name: tags_cache)
-
-    if @question.tags == after_tags && @question.body_markdown == params[:question][:body_markdown] &&
-       @question.title == params[:question][:title]
-      flash[:danger] = "No changes were saved because you didn't edit the post."
-      return redirect_to question_path(@question)
-    end
-
-    body_rendered = helpers.post_markdown(:question, :body_markdown)
-    before = { body: @question.body_markdown, title: @question.title, tags: @question.tags }
-    if @question.update(question_params.merge(tags_cache: tags_cache, body: body_rendered,
-                                              last_activity: DateTime.now, last_activity_by: current_user,
-                                              last_edited_at: DateTime.now, last_edited_by: current_user))
-      PostHistory.post_edited(@question, current_user, before: before[:body],
-                              after: params[:question][:body_markdown], comment: params[:edit_comment],
-                              before_title: before[:title], after_title: params[:question][:title],
-                              before_tags: before[:tags], after_tags: after_tags)
-      redirect_to share_question_path(@question)
-    else
-      render :edit
-    end
-  end
-
-  def update_as_suggested_edit
-    return if check_edits_limit! @question
-
-    body_rendered = helpers.post_markdown(:question, :body_markdown)
-    new_tags_cache = params[:question][:tags_cache]&.reject(&:empty?)
-
-    body_markdown = if params[:question][:body_markdown] != @question.body_markdown
-                      params[:question][:body_markdown]
-                    end
-
-    if @question.tags_cache == new_tags_cache && @question.body_markdown == params[:question][:body_markdown] &&
-       @question.title == params[:question][:title]
-      flash[:danger] = "No changes were saved because you didn't edit the post."
-      return redirect_to question_path(@question)
-    end
-
-    updates = {
-      post: @question,
-      user: current_user,
-      community: @question.community,
-      body: body_rendered,
-      title: params[:question][:title] == @question.title ? nil : params[:question][:title],
-      tags_cache: new_tags_cache == @question.tags_cache ? @question.tags_cache : new_tags_cache,
-      body_markdown: body_markdown,
-      comment: params[:edit_comment],
-      active: true, accepted: false,
-      decided_at: nil, decided_by: nil,
-      rejected_comment: nil
-    }
-
-    @edit = SuggestedEdit.new(updates)
-    if @edit.save
-      @question.user.create_notification("Edit suggested on your post #{@question.title.truncate(50)}",
-                                         question_url(@question))
-      redirect_to share_question_path(@question)
-    else
-      @question.errors = @edit.errors
-      render :edit
-    end
-  end
-
-  def destroy
-    unless check_your_privilege('flag_curate', @question, false)
-      flash[:danger] = helpers.ability_err_msg(:flag_curate, 'delete this question')
-      redirect_to(question_path(@question)) && return
-    end
-
-    if @question.answer_count.positive? && @question.answers.any? { |a| a.score >= 0.5 }
-      flash[:danger] = 'This question cannot be deleted because it has answers.'
-      redirect_to(question_path(@question)) && return
-    end
-    if @question.deleted
-      flash[:danger] = "Can't delete a deleted question."
-      redirect_to(question_path(@question)) && return
-    end
-
-    if @question.update(deleted: true, deleted_at: DateTime.now, deleted_by: current_user,
-                        last_activity: DateTime.now, last_activity_by: current_user)
-      PostHistory.post_deleted(@question, current_user)
-    else
-      flash[:danger] = "Can't delete this question right now. Try again later."
-    end
-    redirect_to url_for(controller: :questions, action: :show, id: @question.id)
-  end
-
-  def undelete
-    unless check_your_privilege('flag_curate', @question, false)
-      flash[:danger] = helpers.ability_err_msg(:flag_curate, 'undelete this question')
-      redirect_to(question_path(@question)) && return
-    end
-
-    unless @question.deleted
-      flash[:danger] = "Can't undelete an undeleted question."
-      redirect_to(question_path(@question)) && return
-    end
-
-    if @question.deleted_by.is_moderator && !current_user.is_moderator
-      flash[:danger] = 'You cannot undelete this post deleted by a moderator.'
-      redirect_to(question_path(@question)) && return
-    end
-
-    if @question.update(deleted: false, deleted_at: nil, deleted_by: nil,
-                        last_activity: DateTime.now, last_activity_by: current_user)
-      PostHistory.post_undeleted(@question, current_user)
-    else
-      flash[:danger] = "Can't undelete this question right now. Try again later."
-    end
-    redirect_to url_for(controller: :questions, action: :show, id: @question.id)
-  end
-
   def feed
     @questions = Rails.cache.fetch('questions_rss', expires_in: 5.minutes) do
       Question.all.order(created_at: :desc).limit(25)
@@ -188,89 +19,4 @@ class QuestionsController < ApplicationController
       format.rss { render layout: false }
     end
   end
-
-  def close
-    unless check_your_privilege('flag_close', nil, false)
-      render(json: { status: 'failed', message: helpers.ability_err_msg(:flag_close, 'close this question') },
-             status: 403)
-      return
-    end
-
-    if @question.closed
-      render(json: { status: 'failed', message: 'Cannot close a closed question.' }, status: 400)
-      return
-    end
-
-    reason = CloseReason.find_by id: params[:reason_id]
-    if reason.nil?
-      render(json: { status: 'failed', message: 'Close reason not found.' }, status: 404)
-      return
-    end
-
-    if reason.requires_other_post
-      unless Question.exists? params[:other_post]
-        render(json: { status: 'failed', message: 'Invalid input for other post.' }, status: 400)
-        return
-      end
-
-      duplicate_of = Question.find(params[:other_post])
-    else
-      duplicate_of = nil
-    end
-
-    if @question.update(closed: true, closed_by: current_user, closed_at: DateTime.now, last_activity: DateTime.now,
-                        last_activity_by: current_user, close_reason: reason, duplicate_post: duplicate_of)
-      PostHistory.question_closed(@question, current_user)
-      render json: { status: 'success' }
-    else
-      render json: { status: 'failed', message: "Can't close this question right now. Try again later.",
-                     errors: @question.errors.full_messages }
-    end
-  end
-
-  def reopen
-    unless check_your_privilege('flag_close', nil, false)
-      flash[:danger] = helpers.ability_err_msg(:flag_close, 'reopen this question')
-      redirect_to(question_path(@question)) && return
-    end
-
-    unless @question.closed
-      flash[:danger] = 'Cannot reopen an open question.'
-      redirect_to(question_path(@question)) && return
-    end
-
-    if @question.update(closed: false, closed_by: current_user, closed_at: Time.now,
-                        last_activity: DateTime.now, last_activity_by: current_user,
-                        close_reason: nil, duplicate_post: nil)
-      PostHistory.question_reopened(@question, current_user)
-    else
-      flash[:danger] = "Can't reopen this question right now. Try again later."
-    end
-    redirect_to question_path(@question)
-  end
-
-  private
-
-  def question_params
-    params.require(:question).permit(:body_markdown, :title, :tags_cache)
-  end
-
-  def set_question
-    @question = Question.find params[:id]
-  rescue
-    if current_user&.privilege?('flag_curate')
-      @question ||= Question.unscoped.find params[:id]
-    end
-    if @question.nil?
-      not_found
-      return
-    end
-    unless @question.post_type_id == Question.post_type_id
-      not_found
-    end
-  end
-
-  def check_if_question_locked
-    check_if_locked(@question)
-  end
 end
diff --git a/app/controllers/site_settings_controller.rb b/app/controllers/site_settings_controller.rb
index 47c2e8187a1ee33aac9776fc8c7a7ba1c4c7df90..b24331ade46b26ca5ea87a12505425458fc6493c 100644
--- a/app/controllers/site_settings_controller.rb
+++ b/app/controllers/site_settings_controller.rb
@@ -27,7 +27,7 @@ class SiteSettingsController < ApplicationController
   end
 
   def update
-    if !params[:community_id].present? && !current_user.is_global_admin
+    if params[:community_id].blank? && !current_user.is_global_admin
       not_found
       return
     end
diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb
index 37db8c4bffa978e8094cd1aa9ba28c6c61b1c6a4..5e867048a2511f3d1b0ace5b5bb3346897fd4dfa 100644
--- a/app/controllers/subscriptions_controller.rb
+++ b/app/controllers/subscriptions_controller.rb
@@ -12,9 +12,9 @@ class SubscriptionsController < ApplicationController
     @subscription = Subscription.new sub_params.merge(user: current_user)
     if @subscription.save
       flash[:success] = 'Your subscription was saved successfully.'
-      redirect_to params[:return_to].present? ? params[:return_to] : root_path
+      redirect_to params[:return_to].presence || root_path
     else
-      render :error, status: 500
+      render :error, status: :internal_server_error
     end
   end
 
@@ -28,10 +28,11 @@ class SubscriptionsController < ApplicationController
       if @subscription.update(enabled: params[:enabled] || false)
         render json: { status: 'success', subscription: @subscription }
       else
-        render json: { status: 'failed' }, status: 500
+        render json: { status: 'failed' }, status: :internal_server_error
       end
     else
-      render json: { status: 'failed', message: 'You do not have permission to update this subscription.' }, status: 403
+      render json: { status: 'failed', message: 'You do not have permission to update this subscription.' },
+             status: :forbidden
     end
   end
 
@@ -41,10 +42,11 @@ class SubscriptionsController < ApplicationController
       if @subscription.destroy
         render json: { status: 'success' }
       else
-        render json: { status: 'failed' }, status: 500
+        render json: { status: 'failed' }, status: :internal_server_error
       end
     else
-      render json: { status: 'failed', message: 'You do not have permission to remove this subscription.' }, status: 403
+      render json: { status: 'failed', message: 'You do not have permission to remove this subscription.' },
+             status: :forbidden
     end
   end
 
diff --git a/app/controllers/suggested_edit_controller.rb b/app/controllers/suggested_edit_controller.rb
index 4ce929743580fdd4df9786f91441cc90f8df07bc..4e169347537d6a7f855d6fd5514226438a84452d 100644
--- a/app/controllers/suggested_edit_controller.rb
+++ b/app/controllers/suggested_edit_controller.rb
@@ -7,22 +7,20 @@ class SuggestedEditController < ApplicationController
 
   def approve
     unless @edit.active?
-      render json: { status: 'error', message: 'This edit has already been reviewed.' }, status: 409
+      render json: { status: 'error', message: 'This edit has already been reviewed.' }, status: :conflict
       return
     end
 
     @post = @edit.post
     unless check_your_privilege('edit_posts', @post, false)
       render(json: { status: 'error', message: helpers.ability_err_msg(:edit_posts, 'review suggested edits') },
-             status: 400)
+             status: :bad_request)
 
       return
     end
 
-    opts = { before: @post.body_markdown, after: @edit.body_markdown, comment: params[:edit_comment] }
-    if @post.question? || @post.article?
-      opts.merge(before_title: @post.title, after_title: @edit.title, before_tags: @post.tags, after_tags: @edit.tags)
-    end
+    opts = { before: @post.body_markdown, after: @edit.body_markdown, comment: params[:edit_comment],
+             before_title: @post.title, after_title: @edit.title, before_tags: @post.tags, after_tags: @edit.tags }
 
     before = { before_body: @post.body, before_body_markdown: @post.body_markdown, before_tags_cache: @post.tags_cache,
                before_tags: @post.tags.to_a, before_title: @post.title }
@@ -33,26 +31,15 @@ class SuggestedEditController < ApplicationController
       PostHistory.post_edited(@post, @edit.user, **opts)
       flash[:success] = 'Edit approved successfully.'
       AbilityQueue.add(@edit.user, "Suggested Edit Approved ##{@edit.id}")
-      if @post.question?
-        render(json: { status: 'success', redirect_url: url_for(controller: :posts, action: :share_q, id: @post.id) })
-
-      elsif @post.answer?
-        render(json: { status: 'success', redirect_url: url_for(controller: :posts, action: :share_a,
-                                                        qid: @post.parent.id, id: @post.id) })
-      elsif @post.article?
-        render(json: { status: 'success', redirect_url: url_for(controller: :articles, action: :share, id: @post.id) })
-      else
-        render(json: { status: 'error', message: 'Could not approve suggested edit.' }, status: 400)
-      end
+      render json: { status: 'success', redirect_url: post_path(@post) }
     else
-      render json: { status: 'error',
-                     message: @post.errors.full_messages.join(', ') }, status: 400
+      render json: { status: 'error', message: @post.errors.full_messages.join(', ') }, status: :bad_request
     end
   end
 
   def reject
     unless @edit.active?
-      render json: { status: 'error', message: 'This edit has already been reviewed.' }, status: 409
+      render json: { status: 'error', message: 'This edit has already been reviewed.' }, status: :conflict
       return
     end
 
@@ -60,7 +47,7 @@ class SuggestedEditController < ApplicationController
 
     unless check_your_privilege('edit_posts', @post, false)
       render(json: { status: 'error', redirect_url: helpers.ability_err_msg(:edit_posts, 'review suggested edits') },
-             status: 400)
+             status: :bad_request)
 
       return
     end
@@ -71,18 +58,10 @@ class SuggestedEditController < ApplicationController
                                                     decided_by: current_user, updated_at: now)
       flash[:success] = 'Edit rejected successfully.'
       AbilityQueue.add(@edit.user, "Suggested Edit Rejected ##{@edit.id}")
-      if @post.question?
-        render(json: { status: 'success', redirect_url: url_for(controller: :posts, action: :share_q,
-                                                                id: @post.id) })
-      elsif @post.answer?
-        render(json: { status: 'success', redirect_url: url_for(controller: :posts, action: :share_a,
-                                                        qid: @post.parent.id, id: @post.id) })
-      elsif @post.article?
-        render(json: { status: 'success', redirect_url: url_for(controller: :articles, action: :share,
-          id: @post.id) })
-      end
+      render json: { status: 'success', redirect_url: helpers.generic_share_link(@post) }
     else
-      render(json: { status: 'error', redirect_url: 'Cannot reject this suggested edit... Strange.' }, status: 400)
+      render json: { status: 'error', redirect_url: 'Cannot reject this suggested edit... Strange.' },
+             status: :bad_request
     end
   end
 
@@ -93,26 +72,15 @@ class SuggestedEditController < ApplicationController
   end
 
   def applied_details
-    if @post.question? || @post.article?
-      {
-        title: @edit.title,
-        tags_cache: @edit.tags_cache&.reject(&:empty?),
-        body: @edit.body,
-        body_markdown: @edit.body_markdown,
-        last_activity: DateTime.now,
-        last_activity_by: @edit.user,
-        last_edited_at: DateTime.now,
-        last_edited_by_id: @edit.user
-      }.compact
-    elsif @post.answer?
-      {
-        body: @edit.body,
-        body_markdown: @edit.body_markdown,
-        last_activity: DateTime.now,
-        last_activity_by: @edit.user,
-        last_edited_at: DateTime.now,
-        last_edited_by_id: @edit.user
-      }.compact
-    end
+    {
+      title: @edit.title,
+      tags_cache: @edit.tags_cache&.reject(&:empty?),
+      body: @edit.body,
+      body_markdown: @edit.body_markdown,
+      last_activity: DateTime.now,
+      last_activity_by: @edit.user,
+      last_edited_at: DateTime.now,
+      last_edited_by_id: @edit.user
+    }.compact
   end
 end
diff --git a/app/controllers/suspicious_votes_controller.rb b/app/controllers/suspicious_votes_controller.rb
index e4fa8db4eaf60b3ffef6c2ad150567b09065bff0..7092843a81e11a35706f26dcf94afd9d89f62e01 100644
--- a/app/controllers/suspicious_votes_controller.rb
+++ b/app/controllers/suspicious_votes_controller.rb
@@ -15,12 +15,12 @@ class SuspiciousVotesController < ApplicationController
   def investigated
     @sv = SuspiciousVote.find params[:id]
     @sv.was_investigated = true
-    @sv.investigated_at = Time.now
+    @sv.investigated_at = Time.zone.now
     @sv.investigated_by = current_user.id
     if @sv.save
       render json: { status: 'success' }
     else
-      render json: { status: 'failed' }, status: 500
+      render json: { status: 'failed' }, status: :internal_server_error
     end
   end
 end
diff --git a/app/controllers/tag_sets_controller.rb b/app/controllers/tag_sets_controller.rb
index cfe1cbaa7a6c534d018018e96f8934a9a691ab63..dc23904ec2869814b2505628038f71e7fc96faed 100644
--- a/app/controllers/tag_sets_controller.rb
+++ b/app/controllers/tag_sets_controller.rb
@@ -29,7 +29,7 @@ class TagSetsController < ApplicationController
       AuditLog.admin_audit(event_type: 'tag_set_update', related: @tag_set, user: current_user,
                            comment: "from <<TagSet #{before}>>\nto <<TagSet #{@tag_set.attributes_print}>>")
     else
-      render json: { tag_set: @tag_set, status: 'failed' }, status: 500
+      render json: { tag_set: @tag_set, status: 'failed' }, status: :internal_server_error
     end
   end
 
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 1d4cb52e4bc460bf95fc594e5bd90dee0fbcbeca..7cb4674ecd5bdd2538d48fbc6f95697b4d86face 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -66,7 +66,7 @@ class TagsController < ApplicationController
     if @tag.update(tag_params.merge(wiki: wiki_md.present? ? helpers.render_markdown(wiki_md) : nil))
       redirect_to tag_path(id: @category.id, tag_id: @tag.id)
     else
-      render :edit, status: 400
+      render :edit, status: :bad_request
     end
   end
 
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 95bdd30af9487d2c2616702ed2abcea0478c3890..f494965c3700e45e5bfb2f0a533fb8ef5355e847 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,4 +1,5 @@
 require 'net/http'
+
 # rubocop:disable Metrics/ClassLength
 class UsersController < ApplicationController
   include Devise::Controllers::Rememberable
@@ -128,12 +129,14 @@ class UsersController < ApplicationController
 
   def destroy
     if @user.votes.count > 100
-      render(json: { status: 'failed', message: 'Users with more than 100 votes cannot be destroyed.' }, status: 422)
+      render json: { status: 'failed', message: 'Users with more than 100 votes cannot be destroyed.' },
+             status: :unprocessable_entity
       return
     end
 
     if @user.is_admin || @user.is_moderator
-      render(json: { status: 'failed', message: 'Admins and moderators cannot be destroyed.' }, status: 422)
+      render json: { status: 'failed', message: 'Admins and moderators cannot be destroyed.' },
+             status: :unprocessable_entity
       return
     end
 
@@ -149,13 +152,14 @@ class UsersController < ApplicationController
     else
       render json: { status: 'failed',
                      message: 'Call to <code>@user.destroy!</code> failed; ask a DBA or dev to destroy.' },
-             status: 500
+             status: :internal_server_error
     end
   end
 
   def soft_delete
     if @user.is_admin || @user.is_moderator
-      render(json: { status: 'failed', message: 'Admins and moderators cannot be deleted.' }, status: 422)
+      render json: { status: 'failed', message: 'Admins and moderators cannot be deleted.' },
+             status: :unprocessable_entity
       return
     end
 
@@ -178,7 +182,7 @@ class UsersController < ApplicationController
 
     before = @user.attributes_print
     unless @user.destroy
-      render(json: { status: 'failed', message: "Failed to destroy UID #{@user.id}" }, status: 500)
+      render(json: { status: 'failed', message: "Failed to destroy UID #{@user.id}" }, status: :internal_server_error)
       return
     end
     AuditLog.moderator_audit(event_type: 'user_delete', user: current_user, comment: "<<User #{before}>>")
@@ -230,7 +234,7 @@ class UsersController < ApplicationController
     permission_map = { mod: :is_admin, admin: :is_global_admin, mod_global: :is_global_admin,
     admin_global: :is_global_admin, staff: :staff }
     unless role_map.keys.include?(params[:role].underscore.to_sym)
-      render json: { status: 'error', message: "Role not found: #{params[:role]}" }, status: 400
+      render json: { status: 'error', message: "Role not found: #{params[:role]}" }, status: :bad_request
     end
 
     key = params[:role].underscore.to_sym
diff --git a/app/controllers/votes_controller.rb b/app/controllers/votes_controller.rb
index 0fb93435d3f84f62bff9889dafb082634088e83a..cad1790c7bc9684b9c2b4dbe4dd7fc6909b3403e 100644
--- a/app/controllers/votes_controller.rb
+++ b/app/controllers/votes_controller.rb
@@ -7,10 +7,10 @@ class VotesController < ApplicationController
     post = Post.find(params[:post_id])
 
     if post.user == current_user && !SiteSetting['AllowSelfVotes']
-      render(json: { status: 'failed', message: 'You may not vote on your own posts.' }, status: 403) && return
+      render(json: { status: 'failed', message: 'You may not vote on your own posts.' }, status: :forbidden) && return
     end
 
-    recent_votes = Vote.where(created_at: 24.hours.ago..Time.now, user: current_user) \
+    recent_votes = Vote.where(created_at: 24.hours.ago..Time.zone.now, user: current_user) \
                        .where.not(post: Post.includes(:parent).where(parents_posts: { user_id: current_user.id })).count
     max_votes_per_day = SiteSetting[current_user.privilege?('unrestricted') ? 'RL_Votes' : 'RL_NewUserVotes']
 
@@ -22,7 +22,7 @@ class VotesController < ApplicationController
       AuditLog.rate_limit_log(event_type: 'vote', related: post, user: current_user,
                             comment: "limit: #{max_votes_per_day}\n\nvote:\n#{params[:vote_type].to_i}")
 
-      render json: { status: 'failed', message: vote_limit_msg }, status: 403
+      render json: { status: 'failed', message: vote_limit_msg }, status: :forbidden
       return
     end
 
@@ -30,7 +30,7 @@ class VotesController < ApplicationController
     vote = post.votes.create(user: current_user, vote_type: params[:vote_type].to_i, recv_user: post.user)
 
     if vote.errors.any?
-      render json: { status: 'failed', message: vote.errors.full_messages.join('. ') }, status: 403
+      render json: { status: 'failed', message: vote.errors.full_messages.join('. ') }, status: :forbidden
       return
     end
 
@@ -48,14 +48,15 @@ class VotesController < ApplicationController
     post = vote.post
 
     if vote.user != current_user
-      render(json: { status: 'failed', message: 'You are not authorized to remove this vote.' }, status: 403) && return
+      render json: { status: 'failed', message: 'You are not authorized to remove this vote.' }, status: :forbidden
+      return
     end
 
     if vote.destroy
       AbilityQueue.add(post.user, "Vote Change on ##{post.id}")
       render json: { status: 'OK', upvotes: post.upvote_count, downvotes: post.downvote_count }
     else
-      render json: { status: 'failed', message: vote.errors.full_messages.join('. ') }, status: 403
+      render json: { status: 'failed', message: vote.errors.full_messages.join('. ') }, status: :forbidden
     end
   end
 
@@ -63,7 +64,7 @@ class VotesController < ApplicationController
 
   def auth_for_voting
     unless user_signed_in?
-      render json: { status: 'failed', message: 'You must be logged in to vote.' }, status: 403
+      render json: { status: 'failed', message: 'You must be logged in to vote.' }, status: :forbidden
     end
   end
 
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 613247e784a93476b60c47d22a8bb0469646bd87..d62eceb6397dc2ba1d564c41ef51663fa1f7c9bc 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -75,39 +75,31 @@ module ApplicationHelper
   end
 
   def generic_share_link(post)
-    case post.post_type_id
-    when Question.post_type_id
-      share_question_url(post)
-    when Answer.post_type_id
-      share_answer_url(qid: post.parent_id, id: post.id)
-    when Article.post_type_id
-      share_article_url(post)
+    if second_level_post_types.include?(post.post_type_id)
+      post_url(post, anchor: "answer-#{post.id}")
     else
-      '#'
+      post_url(post)
     end
   end
 
   def generic_edit_link(post)
-    case post.post_type_id
-    when Question.post_type_id
-      edit_question_url(post)
-    when Answer.post_type_id
-      edit_answer_url(post)
-    when Article.post_type_id
-      edit_article_url(post)
-    else
-      '#'
-    end
+    edit_post_url(post)
   end
 
   def generic_show_link(post)
-    case post.post_type_id
-    when Question.post_type_id
-      question_url(post)
-    when Article.post_type_id
-      article_url(post)
+    if top_level_post_types.include? post.post_type_id
+      post_url(post)
+    elsif second_level_post_types.include?(post.post_type_id)
+      post_url(post.parent, anchor: "answer-#{post.id}")
     else
-      '#'
+      case post.post_type_id
+      when HelpDoc.post_type_id
+        help_path(post.doc_slug)
+      when PolicyDoc.post_type_id
+        policy_path(post.doc_slug)
+      else
+        '#'
+      end
     end
   end
 
@@ -132,4 +124,12 @@ module ApplicationHelper
       false
     end
   end
+
+  def i18ns(key, **subs)
+    s = I18n.t key
+    subs.each do |f, r|
+      s = s.gsub ":#{f}", r.to_s
+    end
+    s
+  end
 end
diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb
index cfb31edf2eab4ec8428d8149fc71958cc64e9572..4ce1d55e6a29ee3a901aabd31b84fbb76389e386 100644
--- a/app/helpers/categories_helper.rb
+++ b/app/helpers/categories_helper.rb
@@ -6,14 +6,14 @@ module CategoriesHelper
   end
 
   def expandable?
-    (defined?(@category) && !current_page?(new_category_path)) ||
+    (defined?(@category) && !@category.nil? && !current_page?(new_category_path)) ||
       (defined?(@post) && !@post.category.nil?) ||
       (defined?(@question) && !@question.category.nil?) ||
       (defined?(@article) && !@article.category.nil?)
   end
 
   def current_category
-    @current_category ||= if defined? @category
+    @current_category ||= if defined?(@category) && !@category.nil?
                             @category
                           elsif defined?(@post) && !@post.category.nil?
                             @post.category
diff --git a/app/helpers/posts_helper.rb b/app/helpers/posts_helper.rb
index c4cfdc80edab611e74b1fdeb745b79bfe7eb0b1d..23c8f7d06fb38a38b2d2af95209b09146df91fde 100644
--- a/app/helpers/posts_helper.rb
+++ b/app/helpers/posts_helper.rb
@@ -1,10 +1,6 @@
 module PostsHelper
   def post_markdown(scope, field_name)
-    if params['__html'].present?
-      params['__html']
-    else
-      render_markdown(params[scope][field_name])
-    end
+    params['__html'].presence || render_markdown(params[scope][field_name])
   end
 
   class PostScrubber < Rails::Html::PermitScrubber
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index d1b6d9e526260cd6b510bdc195104e62ffa6e8bc..5e66a022f919d9772bc81af24495186d067abcee 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -26,33 +26,33 @@ module SearchHelper
         next unless value.match?(valid_value[:numeric])
 
         operator, val = numeric_value_sql value
-        ["score #{operator.present? ? operator : '='} ?", val.to_f]
+        ["score #{operator.presence || '='} ?", val.to_f]
       when 'created'
         next unless value.match?(valid_value[:date])
 
         operator, val, timeframe = date_value_sql value
-        ["created_at #{operator.present? ? operator : '='} DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? #{timeframe})",
+        ["created_at #{operator.presence || '='} DATE_SUB(CURRENT_TIMESTAMP, INTERVAL ? #{timeframe})",
          val.to_i]
       when 'user'
         next unless value.match?(valid_value[:numeric])
 
         operator, val = numeric_value_sql value
-        ["user_id #{operator.present? ? operator : '='} ?", val.to_i]
+        ["user_id #{operator.presence || '='} ?", val.to_i]
       when 'upvotes'
         next unless value.match?(valid_value[:numeric])
 
         operator, val = numeric_value_sql value
-        ["upvotes #{operator.present? ? operator : '='} ?", val.to_i]
+        ["upvotes #{operator.presence || '='} ?", val.to_i]
       when 'downvotes'
         next unless value.match?(valid_value[:numeric])
 
         operator, val = numeric_value_sql value
-        ["downvotes #{operator.present? ? operator : '='} ?", val.to_i]
+        ["downvotes #{operator.presence || '='} ?", val.to_i]
       when 'votes'
         next unless value.match?(valid_value[:numeric])
 
         operator, val = numeric_value_sql value
-        ["(upvotes - downvotes) #{operator.present? ? operator : '='}", val.to_i]
+        ["(upvotes - downvotes) #{operator.presence || '='}", val.to_i]
       end
     end.compact
 
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index fad87a7012bb6a7dc89ca1b43fb383bc2c751c8d..223980ca78ed9546cde975922dc8a40329045986 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -94,7 +94,7 @@ module UserSortable
     default = term_opts[:default] || :created_at
     requested = term_opts[:term]
     direction = term_opts[:direction] || :desc
-    if requested.nil? || !field_mappings.include?(requested.to_sym)
+    if requested.nil? || field_mappings.exclude?(requested.to_sym)
       $active_search_param = default
       default.is_a?(Symbol) ? order(default => direction) : order(default)
     else
diff --git a/app/models/audit_log.rb b/app/models/audit_log.rb
index 2d32dde131146c5199a3a35d476feabc56b3a01f..04bc34f34a0e8254fc8a13ec6ef9ad1f20596a06 100644
--- a/app/models/audit_log.rb
+++ b/app/models/audit_log.rb
@@ -1,8 +1,8 @@
 class AuditLog < ApplicationRecord
   include CommunityRelated
 
-  belongs_to :related, polymorphic: true, required: false
-  belongs_to :user, required: false
+  belongs_to :related, polymorphic: true, optional: true
+  belongs_to :user, optional: true
 
   class << self
     [:admin_audit, :moderator_audit, :action_audit, :user_annotation, :user_history, :block_log,
diff --git a/app/models/community_user.rb b/app/models/community_user.rb
index 61d3a72617906531988f4f9389ebfbcf8317eb8f..897672e95f4752708767345416f3ec2c5c07f551 100644
--- a/app/models/community_user.rb
+++ b/app/models/community_user.rb
@@ -96,6 +96,7 @@ class CommunityUser < ApplicationRecord
 
     # If not sandbox mode, create new privilege entry
     grant_privilege(internal_id) unless sandbox
+    recalc_trust_level unless sandbox
     true
   end
 
@@ -112,4 +113,24 @@ class CommunityUser < ApplicationRecord
   def prevent_ulysses_case
     recalc_privileges
   end
+
+  def trust_level
+    attributes['trust_level'] || recalc_trust_level
+  end
+
+  def recalc_trust_level
+    trust = if user.staff?
+              5
+            elsif is_moderator || user.is_global_moderator || is_admin || user.is_global_admin
+              4
+            elsif privilege?('flag_close') || privilege?('edit_posts')
+              3
+            elsif privilege?('unrestricted')
+              2
+            else
+              1
+            end
+    update(trust_level: trust)
+    trust
+  end
 end
diff --git a/app/models/concerns/maybe_community_related.rb b/app/models/concerns/maybe_community_related.rb
index adf7d8fd3c7e0cafe5f767798cb32dd0dcef4ffe..065687269f276155b3d76f7dd8c78cdbf93ecd21 100644
--- a/app/models/concerns/maybe_community_related.rb
+++ b/app/models/concerns/maybe_community_related.rb
@@ -2,7 +2,7 @@ module MaybeCommunityRelated
   extend ActiveSupport::Concern
 
   included do
-    belongs_to :community, required: false
+    belongs_to :community, optional: true
     default_scope { where(community_id: RequestContext.community_id).or(where(community_id: nil)) }
   end
 end
diff --git a/app/models/error_log.rb b/app/models/error_log.rb
index 0e666979a01cda0356a110806dfea472323651cb..17980399038f1697450d31e688e1dd26afc5aefb 100644
--- a/app/models/error_log.rb
+++ b/app/models/error_log.rb
@@ -1,4 +1,4 @@
 class ErrorLog < ApplicationRecord
-  belongs_to :community, required: false
-  belongs_to :user, required: false
+  belongs_to :community, optional: true
+  belongs_to :user, optional: true
 end
diff --git a/app/models/flag.rb b/app/models/flag.rb
index 0367f7aefad40348f9fcecd5e9ab5ec52399004d..eac0d2554a31eace707610a289fba2756d53d2f2 100644
--- a/app/models/flag.rb
+++ b/app/models/flag.rb
@@ -2,7 +2,7 @@
 class Flag < ApplicationRecord
   include PostRelated
   belongs_to :user
-  belongs_to :handled_by, class_name: 'User', required: false
+  belongs_to :handled_by, class_name: 'User', optional: true
   belongs_to :post_flag_type
 
   scope :handled, -> { where.not(status: nil) }
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 0898eb785b2493f494d722061cdffdefb70fdcaa..387b94ed34b1037bc44a3ce8a32e5a57e8b4674e 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -2,7 +2,5 @@ class Notification < ApplicationRecord
   include CommunityRelated
   belongs_to :user
 
-  def community_name
-    community.name
-  end
+  delegate :name, to: :community, prefix: true
 end
diff --git a/app/models/post.rb b/app/models/post.rb
index e36f2f5b7eef989ccee3a630a456ef81e5031254..98e9edbbcc9d84c676b775f0f93b8407d1cc6df7 100644
--- a/app/models/post.rb
+++ b/app/models/post.rb
@@ -3,14 +3,16 @@ class Post < ApplicationRecord
 
   belongs_to :user
   belongs_to :post_type
-  belongs_to :parent, class_name: 'Post', required: false
-  belongs_to :closed_by, class_name: 'User', required: false
-  belongs_to :deleted_by, class_name: 'User', required: false
-  belongs_to :last_activity_by, class_name: 'User', required: false
-  belongs_to :locked_by, class_name: 'User', required: false
-  belongs_to :last_edited_by, class_name: 'User', required: false
-  belongs_to :category, required: false
-  belongs_to :license, required: false
+  belongs_to :parent, class_name: 'Post', optional: true
+  belongs_to :closed_by, class_name: 'User', optional: true
+  belongs_to :deleted_by, class_name: 'User', optional: true
+  belongs_to :last_activity_by, class_name: 'User', optional: true
+  belongs_to :locked_by, class_name: 'User', optional: true
+  belongs_to :last_edited_by, class_name: 'User', optional: true
+  belongs_to :category, optional: true
+  belongs_to :license, optional: true
+  belongs_to :close_reason, optional: true
+  belongs_to :duplicate_post, class_name: 'Question', optional: true
   has_and_belongs_to_many :tags, dependent: :destroy
   has_many :votes, dependent: :destroy
   has_many :comments, dependent: :destroy
@@ -41,14 +43,14 @@ class Post < ApplicationRecord
   scope :qa_only, -> { where(post_type_id: [Question.post_type_id, Answer.post_type_id, Article.post_type_id]) }
   scope :list_includes, -> { includes(:user, :tags, user: :avatar_attachment) }
 
+  before_validation :update_tag_associations, if: -> { question? || article? }
+  after_create :create_initial_revision
+  after_create :add_license_if_nil
   after_save :check_attribution_notice
   after_save :modify_author_reputation
   after_save :copy_last_activity_to_parent
   after_save :break_description_cache
   after_save :update_category_activity, if: -> { question? || article? }
-  before_validation :update_tag_associations, if: -> { question? || article? }
-  after_create :create_initial_revision
-  after_create :add_license_if_nil
   after_save :recalc_score
 
   def self.search(term)
@@ -63,7 +65,7 @@ class Post < ApplicationRecord
     end
   end
 
-  PostType.all.each do |pt|
+  PostType.all.find_each do |pt|
     define_method "#{pt.name.underscore}?" do
       post_type_id == pt.id
     end
diff --git a/app/models/question.rb b/app/models/question.rb
index b0a347e7e0a0e11e29982e6d6f56a1ecb1cec626..85d63310c74ff41449674029f02bad3f8df25607 100644
--- a/app/models/question.rb
+++ b/app/models/question.rb
@@ -4,9 +4,6 @@ class Question < Post
   scope :meta, -> { joins(:category).where(categories: { name: 'Meta' }) }
   scope :main, -> { joins(:category).where(categories: { name: 'Main' }) }
 
-  belongs_to :close_reason, optional: true
-  belongs_to :duplicate_post, class_name: 'Question', optional: true
-
   def self.post_type_id
     PostType.mapping['Question']
   end
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index c6f6c5844502fcbfdbe220deee0bf35d5b0dd94b..bb7447f134a4da7bf8777f4d594369fb4f2d3117 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -37,11 +37,11 @@ class Subscription < ApplicationRecord
   def qualifier_presence
     return unless ['tag', 'user', 'category'].include? type
 
-    if type == 'tag' && (!qualifier.present? || Tag.find_by(name: qualifier).nil?)
+    if type == 'tag' && (qualifier.blank? || Tag.find_by(name: qualifier).nil?)
       errors.add(:qualifier, 'must provide a valid tag name for tag subscriptions')
-    elsif type == 'user' && (!qualifier.present? || User.find_by(id: qualifier).nil?)
+    elsif type == 'user' && (qualifier.blank? || User.find_by(id: qualifier).nil?)
       errors.add(:qualifier, 'must provide a valid user ID for user subscriptions')
-    elsif type == 'category' && (!qualifier.present? || Category.find_by(id: qualifier).nil?)
+    elsif type == 'category' && (qualifier.blank? || Category.find_by(id: qualifier).nil?)
       errors.add(:qualifier, 'must provide a valid category ID for category subscriptions')
     end
   end
diff --git a/app/models/suspicious_vote.rb b/app/models/suspicious_vote.rb
index a2961002022c4d180975a7932c75b59c599881a2..27416bebad40de37920ed05589767d274a720d8b 100644
--- a/app/models/suspicious_vote.rb
+++ b/app/models/suspicious_vote.rb
@@ -1,6 +1,6 @@
 class SuspiciousVote < ApplicationRecord
-  belongs_to :from_user, foreign_key: 'from_user_id', class_name: 'User'
-  belongs_to :to_user, foreign_key: 'to_user_id', class_name: 'User'
+  belongs_to :from_user, class_name: 'User'
+  belongs_to :to_user, class_name: 'User'
 
   validates :from_user, uniqueness: { scope: [:to_user] }
 
@@ -9,7 +9,7 @@ class SuspiciousVote < ApplicationRecord
   end
 
   def self.check_for_vote_fraud
-    User.all.each do |u|
+    User.all.find_each do |u|
       votes = u.votes.group(:recv_user_id).count(:recv_user_id)
       total = u.votes.count
       votes.each do |recv_id, cnt|
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 191a1f78b7b5299d6e663a81ae950eae9e7be755..670c16ff4eaa48571ca52d2f753695fdeb3b71f6 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -38,7 +38,7 @@ class Tag < ApplicationRecord
   private
 
   def parent_not_self
-    return unless parent_id.present?
+    return if parent_id.blank?
 
     if parent_id == id
       errors.add(:base, 'A tag cannot be its own parent.')
@@ -46,7 +46,7 @@ class Tag < ApplicationRecord
   end
 
   def parent_not_own_child
-    return unless parent_id.present?
+    return if parent_id.blank?
 
     if all_children.include? parent_id
       errors.add(:base, "The #{parent.name} tag is already a child of this tag.")
diff --git a/app/models/user.rb b/app/models/user.rb
index 9db3fc30ae9c5933221e407fb3be63799345032a..a2679c6042320bd17f932e6ac7e8abe5ceb3a292 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -19,11 +19,11 @@ class User < ApplicationRecord
   has_many :suggested_edits, dependent: :nullify
   has_many :suggested_edits_decided, class_name: 'SuggestedEdit', foreign_key: 'decided_by_id', dependent: :nullify
   has_many :audit_logs, dependent: :nullify
-  has_many :audit_logs_related, class_name: 'AuditLog', foreign_key: 'related_id', dependent: :nullify, as: :related
+  has_many :audit_logs_related, class_name: 'AuditLog', dependent: :nullify, as: :related
   has_many :mod_warning_author, class_name: 'ModWarning', foreign_key: 'author_id', dependent: :nullify
 
   validates :username, presence: true, length: { minimum: 3, maximum: 50 }
-  validates :login_token, uniqueness: { allow_nil: true, allow_blank: true }
+  validates :login_token, uniqueness: { allow_blank: true }
   validate :no_links_in_username
   validate :username_not_fake_admin
   validate :no_blank_unicode_in_username
@@ -31,7 +31,7 @@ class User < ApplicationRecord
   validate :is_not_blocklisted
   validate :email_not_bad_pattern
 
-  delegate :reputation, :reputation=, :privilege?, :privilege, to: :community_user
+  delegate :reputation, :reputation=, :privilege?, :privilege, :trust_level, to: :community_user
 
   after_create :send_welcome_tour_message
 
@@ -83,31 +83,10 @@ class User < ApplicationRecord
     is_global_admin || community_user&.is_admin || false
   end
 
-  def trust_level
-    attributes['trust_level'] || recalc_trust_level
-  end
-
   def rtl_safe_username
     "#{username}\u202D"
   end
 
-  def recalc_trust_level
-    # Temporary hack until we have some things to actually calculate based on.
-    trust = if staff?
-              5
-            elsif is_moderator || is_global_moderator || is_admin || is_global_admin
-              4
-            elsif privilege?('flag_close') || privilege?('edit_posts')
-              3
-            elsif privilege?('unrestricted')
-              2
-            else
-              1
-            end
-    update(trust_level: trust)
-    trust
-  end
-
   def username_not_fake_admin
     admin_badge = SiteSetting['AdminBadgeCharacter']
     mod_badge = SiteSetting['ModBadgeCharacter']
diff --git a/app/models/vote.rb b/app/models/vote.rb
index 90b7e2cfca252c4474840efab97900d9ad1e6066..b0f6e4f471c641992ab2350bd6e72f04ab57bfa7 100644
--- a/app/models/vote.rb
+++ b/app/models/vote.rb
@@ -2,14 +2,14 @@
 # association), and to a user.
 class Vote < ApplicationRecord
   include PostRelated
-  belongs_to :user, required: true
-  belongs_to :recv_user, class_name: 'User', required: true
+  belongs_to :user, optional: false
+  belongs_to :recv_user, class_name: 'User', optional: false
 
   after_create :apply_rep_change
+  after_create :add_counter
   before_destroy :check_valid
   before_destroy :reverse_rep_change
 
-  after_create :add_counter
   after_destroy :remove_counter
 
   validates :vote_type, inclusion: [1, -1]
diff --git a/app/views/answers/_answer.html.erb b/app/views/answers/_answer.html.erb
deleted file mode 100644
index 57af4919a16676cf0867f6418e4b4d1fb297a126..0000000000000000000000000000000000000000
--- a/app/views/answers/_answer.html.erb
+++ /dev/null
@@ -1,14 +0,0 @@
-<div class="question">
-  <div class="q-info-box">
-    <p class="large-text center"><%= answer.score %></p>
-    <p>score</p>
-  </div>
-  <div class="q-info-box">
-    <p><a href="/questions/<%= answer.parent.id %>"><%= answer.parent.title %></a></p>
-    <p>
-      Answered <span title="<%= answer.created_at %>" data-livestamp="<%= answer.created_at.to_time.iso8601 %>"><%= answer.created_at %></span>
-      by <a dir="ltr" href="/users/<%= answer.user.id %>"><%= answer.user.rtl_safe_username %></a>
-    </p>
-  </div>
-  <span class="clearfix"></span>
-</div>
diff --git a/app/views/answers/_new.html.erb b/app/views/answers/_new.html.erb
deleted file mode 100644
index 76ad4eaba24cd12198cec94b7925ab21f0e90a57..0000000000000000000000000000000000000000
--- a/app/views/answers/_new.html.erb
+++ /dev/null
@@ -1,47 +0,0 @@
-<h2>Your Answer</h2>
-<% if answer.errors.any? %>
-  <div class="notice is-danger">
-    <p>The following <%= "error".pluralize(answer.errors.count) %> prevented the answer from being posted:</p>
-    <ul>
-      <% answer.errors.full_messages.each do |e| %>
-        <li><%= e %></li>
-      <% end %>
-    </ul>
-  </div>
-<% end %>
-
-<%= render 'posts/markdown_script' %>
-<%= render 'posts/image_upload' %>
-
-<%= form_for answer, url: create_answer_path do |f| %>
-  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body', post: answer %>
-
-  <div class="form-group">
-    <%= f.label :license_id, 'License', class: 'form-element' %>
-    <% cat = defined?(@category) ? @category : @question.category %>
-    <span class="form-caption">
-      <% site_default = License.site_default %>
-      <% category_default = cat.license %>
-      <% if site_default.present? %>
-        site default: <a href="javascript:void(0)" class="js-license-autofill" data-license-id="<%= site_default.id %>">
-          <%= site_default.name %>
-        </a>
-      <% end %>
-      <% if site_default.present? && category_default.present? %>
-        &middot;
-      <% end %>
-      <% if category_default.present? %>
-        category default: <a href="javascript:void(0)" class="js-license-autofill" data-license-id="<%= category_default.id %>">
-          <%= category_default.name %>
-        </a>
-      <% end %>
-    </span>
-    <%= f.select :license_id, options_for_select(License.enabled.default_order(cat).map { |l| [l.name, l.id] }),
-                 {}, class: 'form-element' %>
-  </div>
-
-  <div class="post-preview"></div>
-  <div class="field">
-    <%= f.submit "Post Answer", class: "button is-filled" %>
-  </div>
-<% end %>
diff --git a/app/views/answers/edit.html.erb b/app/views/answers/edit.html.erb
deleted file mode 100644
index 6a7757331ea6d9f4c4534935a556f65db2dbf7f4..0000000000000000000000000000000000000000
--- a/app/views/answers/edit.html.erb
+++ /dev/null
@@ -1,47 +0,0 @@
-<% content_for :title, "Edit Answer" %>
-
-<h1><%= check_your_post_privilege(@answer, 'edit_posts') ? "Edit Answer" : "Suggest Edit for Answer" %></h1>
-<% if @answer.errors.any? %>
-  <div class="notice is-danger">
-    <p>The following <%= "error".pluralize(@answer.errors.count) %> prevented the answer from being updated:</p>
-    <ul>
-      <% @answer.errors.full_messages.each do |e| %>
-        <li><%= e %></li>
-      <% end %>
-    </ul>
-  </div>
-<% end %>
-
-<%= render 'posts/markdown_script' %>
-<%= render 'posts/image_upload' %>
-
-<%= form_for @answer, url: { controller: :answers, action: :update } do |f| %>
-  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body', post: @answer %>
-  <div class="post-preview"></div>
-  <div class="form-group">
-    <%= label_tag :edit_comment, 'Edit comment', class: "form-element" %>
-    <div class="form-caption">
-      Describe—if necessary—what you are changing and why you are making this edit.
-    </div>
-    <%= text_field_tag :edit_comment, params[:edit_comment], class: 'form-element' %>
-  </div>
-  <div class="form-group">
-    <%= f.submit (check_your_post_privilege(@answer, 'edit_posts') ? "Save changes" : "Suggest changes"), class: "button is-filled" %>
-    <%= link_to 'Cancel', question_path(@answer.parent), class: 'button is-outlined is-muted' %>
-  </div>
-<% end %>
-
-<h2>Question</h2>
-<div class="has-padding-6 has-border-style-solid has-border-width-1 has-border-color-tertiary-050" style="max-height: 500px; overflow: auto;">
-<%= render 'posts/expanded', post: @answer.parent %>
-</div>
-
-<% content_for :sidebar do %>
-  <div class="widget has-margin-4">
-    <h4 class="widget--header has-margin-0">Hints and Tips</h4>
-    <div class="widget--body">
-    <% guidance = @answer.category.answering_guidance_override || SiteSetting['AnsweringGuidance'] %>
-      <%= raw(sanitize(render_markdown(guidance), scrubber: scrubber)) %>
-    </div>
-  </div>
-<% end %>
diff --git a/app/views/answers/new.html.erb b/app/views/answers/new.html.erb
deleted file mode 100644
index 578e61015b553eca671ef855d6682fd1aa653074..0000000000000000000000000000000000000000
--- a/app/views/answers/new.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<%= render 'posts/expanded', post: @question, answer: @answer %>
-<hr/>
-<%= render 'new', answer: @answer, parent: @question %>
\ No newline at end of file
diff --git a/app/views/articles/_form.html.erb b/app/views/articles/_form.html.erb
deleted file mode 100644
index 594316ee5b7a93ac6810684bf5a2f845178e27eb..0000000000000000000000000000000000000000
--- a/app/views/articles/_form.html.erb
+++ /dev/null
@@ -1,48 +0,0 @@
-<%= render 'posts/markdown_script' %>
-
-<% if @article.errors.any? %>
-  <div class="notice is-danger is-filled">
-    The following errors prevented this post from being saved:
-    <ul>
-      <% @article.errors.full_messages.each do |msg| %>
-        <li><%= msg %></li>
-      <% end %>
-    </ul>
-  </div>
-<% end %>
-
-<%= render 'posts/image_upload' %>
-
-<%= form_for @article, url: edit_article_path(@article) do |f| %>
-  <div class="form-group">
-    <%= f.label :title, "Title your post:", class: "form-element" %>
-    <%= f.text_field :title, class: "form-element" %>
-  </div>
-
-  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body', post: @article %>
-
-  <div class="post-preview"></div>
-
-  <div class="form-group">
-    <%= f.label :tags_cache, "Tags", class: "form-element" %>
-    <div class="form-caption">
-      Tags help to categorize posts. Separate them by space. Use hyphens for multiple-word tags.
-    </div>
-    <%= f.select :tags_cache, options_for_select(@article.tags_cache.map { |t| [t, t] }, selected: @article.tags_cache),
-                 { include_blank: true }, multiple: true, class: "form-element js-tag-select",
-                 data: { tag_set: @article.category.tag_set.id } %>
-  </div>
-
-  <div class="form-group">
-    <%= label_tag :edit_comment, 'Edit comment', class: "form-element" %>
-    <div class="form-caption">
-      Describe&mdash;if necessary&mdash;what you are changing and why you are making this edit.
-    </div>
-    <%= text_field_tag :edit_comment, params[:edit_comment], class: 'form-element' %>
-  </div>
-
-  <div class="form-group">
-    <%= f.submit check_your_post_privilege(@article, 'edit_posts') ? "Save changes" : "Suggest changes", class: "button is-filled" %>
-    <%= link_to 'Cancel', article_path(@article), class: 'button is-outlined is-muted' %>
-  </div>
-<% end %>
diff --git a/app/views/articles/edit.html.erb b/app/views/articles/edit.html.erb
deleted file mode 100644
index 6727a85a707a9f1bfb920d6498c26629bb9bfc8f..0000000000000000000000000000000000000000
--- a/app/views/articles/edit.html.erb
+++ /dev/null
@@ -1 +0,0 @@
-<%= render 'form', is_edit: true %>
\ No newline at end of file
diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb
deleted file mode 100644
index ffbd774949cb4b6869468649a2c380dcff20517b..0000000000000000000000000000000000000000
--- a/app/views/articles/show.html.erb
+++ /dev/null
@@ -1,19 +0,0 @@
-<% content_for :title, @article.title.truncate(50) %>
-<% content_for :description do %>
-  <% Rails.cache.fetch "posts/#{@article.id}/description" do %>
-    <%= @article.body_plain[0..74].strip %>...
-  <% end %>
-<% end %>
-
-<% content_for :twitter_card_meta do %>
-  <meta name="twitter:card" content="summary" />
-  <% if @article.user.twitter.present? %>
-    <meta name="twitter:creator" content="@<%= @article.user.twitter %>" />
-  <% end %>
-  <meta property="og:url" content="<%= question_url(@article) %>" />
-  <meta property="og:title" content="<%= @article.title %>" />
-  <meta property="og:description" content="<%= @article.body_plain[0..150].strip %>..." />
-  <meta property="og:image" content="<%= "https://#{RequestContext.community.host}#{SiteSetting['SiteLogoPath']}" %>" />
-<% end %>
-
-<%= render 'posts/expanded', post: @article %>
\ No newline at end of file
diff --git a/app/views/categories/post_types.html.erb b/app/views/categories/post_types.html.erb
new file mode 100644
index 0000000000000000000000000000000000000000..3b594b3277a8fe848b1f1f16c7c46cea115e9f3c
--- /dev/null
+++ b/app/views/categories/post_types.html.erb
@@ -0,0 +1,10 @@
+<h1>What kind of post?</h1>
+<p class="has-font-size-larger has-color-tertiary-500">
+  This category has more than one type of post available. Pick a post type to get started.
+</p>
+
+<% @post_types.each do |pt| %>
+  <h3><%= link_to pt.name.underscore.humanize, new_category_post_path(post_type: pt, category: @category) %></h3>
+  <p class="has-color-tertiary-500"><%= pt.description %></p>
+  <hr/>
+<% end %>
diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb
index 5dbee75d0fa07eab46e124be2e488b6a862fe32b..33fe278446f5cbd9ef258fe32f2ed387fb7bdd34 100644
--- a/app/views/layouts/_header.html.erb
+++ b/app/views/layouts/_header.html.erb
@@ -126,10 +126,8 @@
                     class: "category-header--nav-item #{active?(current_cat) && controller_name != 'tags' ? 'is-active' : ''}" %>
         <%= link_to 'Tags', category_tags_path(current_cat),
                     class: "category-header--nav-item #{active?(current_cat) && controller_name == 'tags' ? 'is-active' : ''}" %>
-        <%# There will eventually be a Tags link here too, once we have a page that lists tags per-tag set. %>
         <div class="category-header--nav-separator"></div>
-        <% ptid = current_cat.display_post_types.reject { |e| e.to_s.empty? }.first || Question.post_type_id %>
-        <%= link_to create_post_path(category_id: current_cat.id, post_type_id: ptid),
+        <%= link_to category_post_types_path(current_cat.id),
                     class: 'category-header--nav-item is-button' do %>
           <%= current_cat.button_text.present? ? current_cat.button_text : 'Create Post' %>
         <% end %>
diff --git a/app/views/moderator/index.html.erb b/app/views/moderator/index.html.erb
index 95fa1c29850cf915aebff5162be14b7343c88837..26b11ae4a1a27377ffd583b9e90a325d79b50acf 100644
--- a/app/views/moderator/index.html.erb
+++ b/app/views/moderator/index.html.erb
@@ -28,7 +28,7 @@
     <div class="widget">
       <div class="widget--body">
         <i class="fas fa-file-alt"></i>
-        <%= link_to 'Create Help Page', new_help_post_path %>
+        <%= link_to 'Create Help Page', new_post_path(post_type: HelpDoc.post_type_id) %>
       </div>
     </div>
   </div>
diff --git a/app/views/posts/_article_list.html.erb b/app/views/posts/_article_list.html.erb
index 607dd804a9dbd013d34a47e2128e7d2015adffb6..3d945a0d2695a0c16326469e82c73d838aafe0d9 100644
--- a/app/views/posts/_article_list.html.erb
+++ b/app/views/posts/_article_list.html.erb
@@ -19,7 +19,7 @@
       <% if @show_category_tag %>
         <span class="badge is-tag is-filled"><%= defined?(@category) ? @category.name : post.category.name %></span>
       <% end %>
-      <%= link_to post.title, share_article_path(post), 'data-ckb-item-link' => '' %>
+      <%= link_to post.title, generic_share_link(post), 'data-ckb-item-link' => '' %>
     </div>
     <% if @last_activity %>
       <p class="has-color-tertiary-600 has-float-right post-list--meta">
diff --git a/app/views/posts/_expanded.html.erb b/app/views/posts/_expanded.html.erb
index f6c43b3cd45556115d4b494d68ce38e82fef8d17..1f39eb34844393aacc75703b6de2bbe647af621d 100644
--- a/app/views/posts/_expanded.html.erb
+++ b/app/views/posts/_expanded.html.erb
@@ -1,52 +1,61 @@
+<%#
+  Full post view, containing all details and interactions.
+  Variables:
+    post : the Post instance to display
+%>
+
+<% category ||= defined?(@category) ? @category : post.category %>
+<% post_type ||= defined?(@post_type) ? @post_type : post.post_type %>
+
 <% is_question = post.post_type_id == Question.post_type_id %>
 <% is_top_level = post.parent_id.nil? %>
-<% has_tags = is_top_level && !post.tag_ids.empty? %>
 
 <div class="post <%= post.meta? ? 'is-meta' : '' %> <%= is_top_level ? '' : 'has-border-bottom-style-solid has-border-bottom-width-1 has-border-color-tertiary-100' %>" data-post-id="<%= post.id %>" id="<%= (is_question ? 'question-' : 'answer-') + post.id.to_s %>" data-ckb-list-item data-ckb-item-type="post" data-ckb-post-id="<%= post.id %>">
   <% if is_top_level %>
     <h1 class="post--title has-border-top-width-4 has-border-top-style-solid has-border-color-<%= post.meta? ? 'tertiary' : 'primary' %>-400 has-padding-2">
       <span class="post--title-text">
         <%= post.title %>
-        <%= is_question && post.closed ? "[closed]" : "" %>
+        <%= post_type.is_closeable && post.closed ? "[closed]" : "" %>
       </span>
-      <% category = defined?(@category) ? @category : post.category %>
       <% if category.display_post_types.reject { |e| e.to_s.empty? }.size > 1 %>
-        <%= post_type_badge(post.post_type.name) %>
+        <%= post_type_badge(post_type.name) %>
       <% end %>
     </h1>
   <% end %>
 
   <div class="post--container <%= 'deleted-content' if post.deleted? %> grid is-nowrap">
-    <div>
-      <div class="post--votes has-text-align-center" title="<%= post.score %>">
-        <% existing_vote = my_vote(post) %>
-        <% unless post.locked? %>
-          <button class="vote-button button is-icon-only-button <%= (existing_vote&.vote_type == 1) ? 'is-active' : '' %>"
-                  data-vote-type="1" data-vote-id="<%= existing_vote&.id %>" id="post-<%= post.id %>-up" aria-label="Upvote">
-              <svg width="2em" height="1.33em" viewbox="0 0 100 50">
-                <path d="M50,0 L100,50 L0,50 Z" fill="currentColor" />
-              </svg>
-          </button>
-        <% end %>
-        <div class="score has-font-size-subheading js-upvote-count">
-          +<%= post.upvote_count %>
-        </div>
-        <div class="score has-font-size-subheading js-downvote-count">
-          &minus;<%= post.downvote_count %>
+    <% if post_type.has_votes %>
+      <div>
+        <div class="post--votes has-text-align-center" title="<%= post.score %>">
+          <% existing_vote = my_vote(post) %>
+          <% unless post.locked? %>
+            <button class="vote-button button is-icon-only-button <%= (existing_vote&.vote_type == 1) ? 'is-active' : '' %>"
+                    data-vote-type="1" data-vote-id="<%= existing_vote&.id %>" id="post-<%= post.id %>-up" aria-label="Upvote">
+                <svg width="2em" height="1.33em" viewbox="0 0 100 50">
+                  <path d="M50,0 L100,50 L0,50 Z" fill="currentColor" />
+                </svg>
+            </button>
+          <% end %>
+          <div class="score has-font-size-subheading js-upvote-count">
+            +<%= post.upvote_count %>
+          </div>
+          <div class="score has-font-size-subheading js-downvote-count">
+            &minus;<%= post.downvote_count %>
+          </div>
+          <% unless post.locked? %>
+            <button class="vote-button button is-icon-only-button <%= (existing_vote&.vote_type == -1) ? 'is-active' : '' %>" <%= (post.locked?) ? 'disabled' : '' %>
+                    data-vote-type="-1" data-vote-id="<%= existing_vote&.id %>" id="post-<%= post.id %>-up" aria-label="Downvote">
+                <svg width="2em" height="1.33em" viewbox="0 0 100 50">
+                  <path d="M0,0 L100,0 L50,50 Z" fill="currentColor" />
+                </svg>
+            </button>
+          <% end %>
         </div>
-        <% unless post.locked? %>
-          <button class="vote-button button is-icon-only-button <%= (existing_vote&.vote_type == -1) ? 'is-active' : '' %>" <%= (post.locked?) ? 'disabled' : '' %>
-                  data-vote-type="-1" data-vote-id="<%= existing_vote&.id %>" id="post-<%= post.id %>-up" aria-label="Downvote">
-              <svg width="2em" height="1.33em" viewbox="0 0 100 50">
-                <path d="M0,0 L100,0 L50,50 Z" fill="currentColor" />
-              </svg>
-          </button>
-        <% end %>
       </div>
-    </div>
+    <% end %>
 
     <div class="post--content has-padding-2">
-      <% if is_question && post.closed %>
+      <% if post_type.is_closeable && post.closed %>
         <div class="notice has-margin-2">
           <p class="has-font-weight-normal">
             <strong>Closed</strong>
@@ -59,7 +68,7 @@
             <p>
               <%= post.close_reason.description.gsub "$SiteName", SiteSetting['SiteName'] %>
               <% if post.close_reason.requires_other_post %>
-                See: <%= link_to post.duplicate_post.title, question_path(post.duplicate_post), class: "has-font-size-larger" %>
+                See: <%= link_to post.duplicate_post.title, post_path(post.duplicate_post), class: "has-font-size-larger" %>
               <% end %>
             </p>
           <% end %>
@@ -100,7 +109,7 @@
         </div>
       <% end %>
 
-      <% if post.pending_suggested_edit? %>
+      <% if post_type.is_public_editable && post.pending_suggested_edit? %>
         <% if check_your_post_privilege(post, 'edit_posts') %>
           <div class="notice h-p-2">
             <p class="h-m-0"><i class="fa fa-pencil-alt h-m-l-2 h-c-red-600"></i> There is a <strong>pending suggested edit</strong> on this post. <a href="<%= suggested_edit_url(post.pending_suggested_edit.id) %>" class="button is-outlined is-muted is-small">Review changes</a></p>
@@ -121,7 +130,7 @@
         <div class="post--meta has-margin-bottom-4">
           <%= render 'users/post_usercard', post: post %>
           <div>
-            <% if has_tags %>
+            <% if post_type.has_tags %>
               <div class="post--tags has-padding-2">
                 <% required_ids = post.category&.required_tag_ids %>
                 <% moderator_ids = post.category&.moderator_tag_ids %>
@@ -135,7 +144,7 @@
                 <% end %>
               </div>
             <% end %>
-            <% if post.license.present? || been_edited %>
+            <% if (post_type.has_license && post.license.present?) || been_edited %>
               <div class="post--licensing--edited has-margin-left-2">
                 <% if post.license.present? %>
                   <i class="fas fa-balance-scale" title="License"></i>
@@ -161,15 +170,15 @@
 
         <div class="post--actions">
           <div class="tools">
-              <%= link_to generic_share_link(post), class: 'tools--item js-permalink' do %>
-                <i class="fa fa-link"></i>
-                <span class="js-text">Permalink</span>
-              <% end %>
-              <%= link_to post_history_path(post), class: 'tools--item' do %>
-                <i class="fa fa-history"></i>
-                History
-              <% end %>
-            <% unless post.locked? %>
+            <%= link_to generic_share_link(post), class: 'tools--item js-permalink' do %>
+              <i class="fa fa-link"></i>
+              <span class="js-text">Permalink</span>
+            <% end %>
+            <%= link_to post_history_path(post), class: 'tools--item' do %>
+              <i class="fa fa-history"></i>
+              History
+            <% end %>
+            <% unless post.locked? || !post_type.is_public_editable %>
               <% if check_your_post_privilege(post, 'edit_post') %>
                 <% if post.pending_suggested_edit? %>
                   <%= link_to suggested_edit_url(post.pending_suggested_edit.id), class: 'tools--item is-danger is-filled' do %>
@@ -177,7 +186,7 @@
                     Review suggested edit
                   <% end %>
                 <% else %>
-                  <%= link_to generic_edit_link(post), class: 'tools--item' do %>
+                  <%= link_to edit_post_path(post), class: 'tools--item' do %>
                     <i class="fa fa-pencil-alt"></i>
                     Edit
                   <% end %>
@@ -186,28 +195,28 @@
                 <% if post.pending_suggested_edit? %>
                   <span class="tools--item">suggested edit pending...</span>
                 <% else %>
-                  <%= link_to generic_edit_link(post), class: 'tools--item' do %>
+                  <%= link_to edit_post_path(post), class: 'tools--item' do %>
                     <i class="fa fa-pencil-alt"></i>
                     Suggest edit
                   <% end %>
                 <% end %>
               <% end %>
             <% end %>
-            <% unless current_user.nil? %>
+            <% if user_signed_in? %>
               <a href="#" class="flag-dialog-link tools--item">
                 <i class="fa fa-flag"></i>
                 Flag
               </a>
             <% end %>
-            <% unless post.locked? %>
+            <% unless post.locked? || !post_type.is_closeable %>
               <% if check_your_privilege('flag_close') %>
-                <% if is_question && !post.closed %>
+                <% if !post.closed %>
                   <a href="#" class="close-dialog-link tools--item">
                     <i class="fa fa-lock"></i>
                     Close
                   </a>
-                <% elsif is_question && post.closed %>
-                  <%= link_to reopen_question_path(post), method: :post, class: 'reopen-question tools--item' do %>
+                <% elsif post.closed %>
+                  <%= link_to reopen_post_path(post), method: :post, class: 'reopen-question tools--item' do %>
                     <i class="fa fa-unlock"></i>
                     Reopen
                   <% end %>
@@ -217,14 +226,14 @@
             <% if check_your_post_privilege(post, 'flag_curate') %>
               <% unless post.locked? %>
                 <% if !post.deleted %>
-                  <%= link_to url_for(controller: post.post_type.name.pluralize.downcase.to_sym, action: :destroy, id: post.id),
-                              method: :delete, data: { confirm: 'Are you sure you want to delete this post?' }, class: "tools--item is-danger" do %>
+                  <%= link_to delete_post_path(post), method: :post,
+                              data: { confirm: 'Are you sure you want to delete this post?' }, class: "tools--item is-danger" do %>
                     <i class="fa fa-trash"></i>
                     Delete
                   <% end %>
                 <% else %>
-                  <%= link_to url_for(controller: post.post_type.name.pluralize.downcase.to_sym, action: :undelete, id: post.id),
-                              method: :post, data: { confirm: 'Restore this post, making it visible to regular users?' }, class: "tools--item is-danger is-filled" do %>
+                  <%= link_to restore_post_path(post), method: :post,
+                              data: { confirm: 'Restore this post, making it visible to regular users?' }, class: "tools--item is-danger is-filled" do %>
                     <i class="fa fa-undo"></i>
                     Restore
                   <% end %>
@@ -236,13 +245,13 @@
                 <i class="fa fa-wrench"></i>
                 Tools
               </a>
-              <% flags_count = if current_user.is_moderator
-                                post.flags
-                              else
-                                post.flags.not_confidential
-                              end.where(handled_by_id: nil).count %>
+              <% flags_count = if current_user&.is_moderator
+                                 post.flags
+                               else
+                                 post.flags.not_confidential
+                               end.where(handled_by_id: nil).count %>
 
-              <% own_flags_count = if current_user.is_moderator
+              <% own_flags_count = if current_user&.is_moderator
                                      0
                                    else
                                      post.flags.not_confidential.where(user: current_user, handled_by_id: nil).count
@@ -251,7 +260,7 @@
               <% if flags_count > 0 %>
                 <a href="#" class="show-all-flags-dialog-link tools--item">
                   <i class="fa fa-exclamation-triangle"></i>
-                  Show <%= pluralize(flags_count - own_flags_count, "flag") %>
+                  Show <%= pluralize(flags_count - own_flags_count, 'flag') %>
                 </a>
               <% end %>
             <% end %>
@@ -288,9 +297,9 @@
                   <div class="grid--cell is-flexible">
                     <label class="form-element has-margin-0" for="flag-reason-other_<%= post.id  %>">
                       other reason
-                      <div class="form-caption">
+                      <span class="form-caption">
                         Please elaborate in the details field below.
-                      </div>
+                      </span>
                     </label>
                   </div>
                 </div>
@@ -310,11 +319,11 @@
           </div>
         </div>
 
-        <% if is_question %>
+        <% if post_type.is_closeable %>
           <div class="post--action-dialog js-close-box">
             <div class="widget">
               <div class="widget--header">Why should this post be closed?</div>
-              <% (@close_reasons || CloseReason.all).each do |reason| %>
+              <% CloseReason.active.each do |reason| %>
                 <div class="widget--body">
                   <div class="grid">
                     <div class="grid--cell">
@@ -335,7 +344,7 @@
                 </div>
               <% end %>
               <div class="widget--footer">
-                <button class="close-question button is-filled is-muted" data-post-type="<%= is_question ? 'Question' : 'Answer' %>" data-post-id="<%= post.id %>">
+                <button class="js-close-question button is-filled is-muted" data-post-id="<%= post.id %>">
                   Close this post
                 </button>
               </div>
@@ -378,7 +387,6 @@
         <% end %>
 
         <div class="post--status">
-
           <% if post.att_source.present? %>
             <div class="notice has-margin-2">
               <p>
@@ -389,8 +397,8 @@
               </p>
             </div>
           <% end %>
-       </div>
-         <div class="post--comments has-padding-4">
+        </div>
+        <div class="post--comments has-padding-4">
           <h4 class="has-margin-0">
             <%= pluralize(post.comments.undeleted.size, 'comment') %>
             <span class="has-color-tertiary-500"><%= (moderator? && post.comments.deleted.size != 0) ? '(deleted: ' + post.comments.deleted.size.to_s + ')' : '' %></span>
diff --git a/app/views/posts/_form.html.erb b/app/views/posts/_form.html.erb
index 91758e9ce08e8a349f07ca4df125c84ed6b919a1..c274a537fc6334d5a4c7c6554b7edbaffbc363ef 100644
--- a/app/views/posts/_form.html.erb
+++ b/app/views/posts/_form.html.erb
@@ -1,81 +1,96 @@
-<% with_post_type ||= false %>
+<%#
+  Post create/edit form covering all post types.
+  Variables:
+    post          : a Post instance to work on (either via .new or .find - doesn't matter if it has content or not)
+    submit_path   : the URL to submit the form to (must accept POST requests)
+    post_type     : a PostType instance describing the requested post type
+    category      : a Category instance, required if the post type has a category, indicating which category to post in
+    parent        : a Post instance containing the parent for this post (required if the post type has a parent)
+    inline_parent : optional, default  true: display an inline copy of the parent? (if present)
+    type_summary  : optional, default  true: display a summary of the requested post type?
+    edit_comment  : optional, default false: include a field for an edit comment?
+%>
+
+<%
+  # defaults
+  category ||= defined?(category) ? category : nil
+  parent ||= defined?(parent) ? parent : nil
+  inline_parent ||= defined?(inline_parent) ? inline_parent : true
+  type_summary ||= defined?(type_summary) ? type_summary : true
+  edit_comment ||= defined?(edit_comment) ? edit_comment : false
+%>
 
 <% content_for :head do %>
   <%= render 'posts/markdown_script' %>
 <% end %>
 
-<div class="notice is-info">
-  <p><strong>Posting Tips</strong></p>
-  <div class="has-font-size-caption">
-  <% guidance = @category.asking_guidance_override %>
-  <%= raw(sanitize(render_markdown(guidance.present? ? guidance : SiteSetting['AskingGuidance']), scrubber: scrubber)) %>
-  </div>
-</div>
-
-<% if @post.errors.any? %>
-  <div class="notice is-danger is-filled">
-    <p>The following errors prevented your post being saved:</p>
-    <ul>
-      <% @post.errors.full_messages.each do |msg| %>
-        <li><%= msg %></li>
-      <% end %>
-    </ul>
-  </div>
-<% end %>
-
 <%= render 'posts/image_upload' %>
 
-<%= form_for @post, url: submit_path, html: { class: 'has-margin-top-4' } do |f| %>
-  <%= f.hidden_field :category_id %>
+<%= form_for post, url: submit_path, html: { class: 'has-margin-top-4' } do |f| %>
+  <% if post.id.nil? %>
+    <%= f.hidden_field :post_type_id, value: post.post_type_id || post_type.id %>
+  <% end %>
 
-  <% if with_post_type %>
-    <div class="form-group">
-      <%= f.label :post_type_id, 'Post type', class: 'form-element' %>
-      <span class="form-caption">What kind of post is this? Questions can have answers; articles only have comments.</span>
-      <% ids = @category.display_post_types.reject { |e| e.to_s.empty? } %>
-      <% post_types = PostType.where(id: ids) %>
-      <% opts = post_types.map { |pt| [pt.name, pt.id] } %>
-      <%= f.select :post_type_id, options_for_select(opts, selected: @post.post_type_id),
-                   { include_blank: true }, class: 'form-element' %>
-    </div>
-  <% else %>
-    <%= f.hidden_field :post_type_id %>
+  <% if post_type.has_category? && post.id.nil? %>
+    <%= f.hidden_field :category_id, value: post.category_id || category&.id || parent&.category_id %>
+  <% end %>
+
+  <% if post_type.has_parent? && post.id.nil? %>
+    <%= f.hidden_field :parent_id, value: post.parent_id || parent&.id %>
+  <% end %>
+
+  <% if type_summary %>
+    <p>
+      <span class="has-font-size-larger">
+        You're writing a new <strong><%= post_type.name %></strong>
+        <% if post_type.has_category? && category.present? %>
+          in <strong><%= category.name %></strong>
+        <% end %>
+      </span><br/>
+      <span class="has-font-size-smaller has-color-tertiary-700">
+        <%= post_type.description %>
+      </span>
+    </p>
   <% end %>
 
-  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body', post: @post %>
+  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body', post: post %>
 
   <div class="post-preview"></div>
 
-  <div class="form-group">
-    <%= f.label :title, 'Summarize your post with a title:', class: 'form-element' %>
-    <%= f.text_field :title, class: 'form-element' %>
-  </div>
+  <% unless post_type.has_parent? %>
+    <div class="form-group">
+      <%= f.label :title, 'Summarize your post with a title:', class: 'form-element' %>
+      <%= f.text_field :title, class: 'form-element' %>
+    </div>
+  <% end %>
 
-  <div class="form-group">
-    <%= f.label :tags_cache, 'Tags (at least one):', class: 'form-element' %>
-    <% required_tags = @category.required_tags.to_a %>
-    <% unless required_tags.empty? %>
-      <span class="form-caption">
-        Requires at least one of
-        <% required_tags.each do |tag| %>
-          <a class="badge is-tag is-filled js-add-required-tag" href="javascript:void(0)" data-tag-id="<%= tag.id %>"
-             data-tag-name="<%= tag.name %>">
-            <%= tag.name %>
-          </a>
-        <% end %>
-      </span>
-    <% end %>
-    <%= f.select :tags_cache, options_for_select(@post.tags_cache.map { |t| [t, t] }, selected: @post.tags_cache),
-                 { include_blank: true }, multiple: true, class: "form-element js-tag-select",
-                 data: { tag_set: @category.tag_set_id } %>
-  </div>
+  <% if post_type.has_tags? && category.present? %>
+    <div class="form-group">
+      <%= f.label :tags_cache, 'Tags (at least one):', class: 'form-element' %>
+      <% required_tags = category.required_tags.to_a %>
+      <% unless required_tags.empty? %>
+        <span class="form-caption">
+          Requires at least one of
+          <% required_tags.each do |tag| %>
+            <a class="badge is-tag is-filled js-add-required-tag" href="javascript:void(0)" data-tag-id="<%= tag.id %>"
+               data-tag-name="<%= tag.name %>">
+              <%= tag.name %>
+            </a>
+          <% end %>
+        </span>
+      <% end %>
+      <%= f.select :tags_cache, options_for_select(post.tags_cache.map { |t| [t, t] }, selected: post.tags_cache),
+                   { include_blank: true }, multiple: true, class: "form-element js-tag-select",
+                   data: { tag_set: category.tag_set_id } %>
+    </div>
+  <% end %>
 
-  <% unless @post.id.present? %>
+  <% if post_type.has_category? && category.present? && post_type.has_license? && !post.id.present? %>
     <div class="form-group">
       <%= f.label :license_id, 'License', class: 'form-element' %>
       <span class="form-caption">
         <% site_default = License.site_default %>
-        <% category_default = @category.license %>
+        <% category_default = category.license %>
         <% if site_default.present? %>
           site default: <a href="javascript:void(0)" class="js-license-autofill" data-license-id="<%= site_default.id %>">
             <%= site_default.name %>
@@ -90,13 +105,58 @@
           </a>
         <% end %>
       </span>
-      <%= f.select :license_id, options_for_select(License.enabled.default_order(@category).map { |l| [l.name, l.id] },
-                                                   selected: @post.license_id), {}, class: 'form-element' %>
+      <%= f.select :license_id, options_for_select(License.enabled.default_order(category).map { |l| [l.name, l.id] },
+                                                   selected: post.license_id), {}, class: 'form-element' %>
+    </div>
+  <% end %>
+
+  <% if [HelpDoc.post_type_id, PolicyDoc.post_type_id].include? post_type.id %>
+    <div class="form-group">
+      <%= f.label :help_category, 'Category', class: 'form-element' %>
+      <span class="form-caption">
+        Name a category under which to display this post in the help center.
+      </span>
+      <%= f.text_field :help_category, class: 'form-element' %>
+    </div>
+
+    <div class="form-group">
+      <%= f.label :help_ordering, 'Order', class: 'form-element' %>
+      <span class="form-caption">
+        Control where this post appears in the list of help articles. Higher values appear later in the list.
+      </span>
+      <%= f.number_field :help_ordering, class: 'form-element' %>
+    </div>
+
+    <div class="form-group">
+      <%= f.label :doc_slug, 'URL slug', class: 'form-element' %>
+      <span class="form-caption">In a URL of "https://yoursite.codidact.com/help/topic", the "topic" is the slug.</span>
+      <%= f.text_field :doc_slug, class: 'form-element' %>
+    </div>
+  <% end %>
+
+  <% if edit_comment %>
+    <hr/>
+    <div class="form-group">
+      <%= label_tag :edit_comment, 'Edit comment', class: 'form-element' %>
+      <%= text_field_tag :edit_comment, nil, class: 'form-element' %>
     </div>
   <% end %>
 
   <div class="actions">
-    <%= f.submit "Save Post in #{@category.name}", class: 'button is-filled' %>
-    <%= link_to 'Cancel', category_path(@category), class: 'button is-muted is-outlined' %>
+    <% if post_type.has_category && category.present? %>
+      <%= f.submit "Save Post in #{category.name}", class: 'button is-filled' %>
+      <%= link_to 'Cancel', category_path(category), class: 'button is-muted is-outlined is-danger',
+                  data: { confirm: 'Any unsaved changes will be lost. Are you sure?' } %>
+    <% else %>
+      <%= f.submit 'Save Post', class: 'button is-filled' %>
+      <%= link_to 'Cancel', :back, class: 'button is-muted is-outlined is-danger',
+                  data: { confirm: 'Any unsaved changes will be lost. Are you sure?' } %>
+    <% end %>
   </div>
-<% end %>
\ No newline at end of file
+<% end %>
+
+<% if inline_parent && defined?(parent) && parent.present? %>
+  <h2>Responding to:</h2>
+
+  <%= render 'posts/expanded', post: parent  %>
+<% end %>
diff --git a/app/views/posts/document.html.erb b/app/views/posts/document.html.erb
index 00e5560a2ee51cad47a051b13a3bd162c0063532..a5cb84a167ba3c82b7ae75a9110be610f0b9a887 100644
--- a/app/views/posts/document.html.erb
+++ b/app/views/posts/document.html.erb
@@ -3,7 +3,7 @@
 <% end %>
 <% unless @post.nil? %>
   <% if (moderator? && @post.post_type_id == HelpDoc.post_type_id) || (admin? && @post.post_type_id == PolicyDoc.post_type_id) %>
-    <%= link_to 'edit', edit_help_post_path(@post), class: "button is-outlined is-muted" %>
+    <%= link_to 'edit', edit_post_path(@post), class: "button is-outlined is-muted" %>
   <% end %>
 <% end %>
 
diff --git a/app/views/posts/edit.html.erb b/app/views/posts/edit.html.erb
new file mode 100644
index 0000000000000000000000000000000000000000..4195ace2658236a6300a76f77fc78b2052643ceb
--- /dev/null
+++ b/app/views/posts/edit.html.erb
@@ -0,0 +1,30 @@
+<h1>
+  Edit <%= @post_type.name.underscore.humanize.titleize %>
+  <% if @category.present? %>
+    in <%= @category.name %>
+  <% end %>
+</h1>
+
+<% if @category.present? %>
+  <div class="notice is-info">
+    <p><strong>Posting Tips</strong></p>
+    <div class="has-font-size-caption">
+      <% guidance = @category.asking_guidance_override %>
+      <%= raw(sanitize(render_markdown(guidance.present? ? guidance : SiteSetting['AskingGuidance']), scrubber: scrubber)) %>
+    </div>
+  </div>
+<% end %>
+
+<% if @post.errors.any? %>
+  <div class="notice is-danger is-filled">
+    <p>The following errors prevented your post being saved:</p>
+    <ul>
+      <% @post.errors.full_messages.each do |msg| %>
+        <li><%= msg %></li>
+      <% end %>
+    </ul>
+  </div>
+<% end %>
+
+<%= render 'form', post: @post, post_type: @post_type, category: @category, submit_path: request.path,
+           edit_comment: true, parent: @post.parent, type_summary: false %>
\ No newline at end of file
diff --git a/app/views/posts/edit_help.html.erb b/app/views/posts/edit_help.html.erb
deleted file mode 100644
index 1122a1799bcacc0a265c803b9a1483fee671438f..0000000000000000000000000000000000000000
--- a/app/views/posts/edit_help.html.erb
+++ /dev/null
@@ -1,39 +0,0 @@
-<% content_for :title, "Editing '#{@post.title.truncate(50)}'" %>
-
-<% if @post.errors.any? %>
-  <div class="notice is-danger">
-    These errors prevented this post being saved:
-    <ul>
-      <% @post.errors.full_messages.each do |msg| %>
-        <li><%= msg %></li>
-      <% end %>
-    </ul>
-  </div>
-<% end %>
-
-<h1>Edit <%= @post.policy_doc? ? 'Policy' : (@post.help_doc? ? 'Help Article' : 'Post') %></h1>
-<%= form_for @post, url: update_help_post_path(@post) do |f| %>
-  <div class="form-group">
-    <%= f.label :title, "Title your post:", class: "form-element" %>
-    <%= f.text_field :title, class: "form-element" %>
-  </div>
-  <div class="form-group">
-    <%= f.label :body_markdown, 'Body', class: "form-element" %>
-    <%= f.text_area :body_markdown, { class: "form-element is-large post-field", rows: 15 } %>
-  </div>
-  <div class="form-group">
-    <%= f.label :help_category, 'Category', class: 'form-element' %>
-    <span class="form-caption">
-      Name a category under which to display this post in the help center.
-    </span>
-    <%= f.text_field :help_category, class: 'form-element' %>
-  </div>
-  <div class="form-group">
-    <%= f.label :help_ordering, 'Order', class: 'form-element' %>
-    <span class="form-caption">
-      Control where this post appears in the list of help articles. Higher values appear later in the list.
-    </span>
-    <%= f.number_field :help_ordering, class: 'form-element' %>
-  </div>
-  <%= f.submit "Update", class: "button is-filled is-very-large" %>
-<% end %>
diff --git a/app/views/posts/new.html.erb b/app/views/posts/new.html.erb
index 1db9ccf3fdc9292ecaef382020eb94e25d15532c..c7a1a1fbfecfbb11c5c5754aaa9ba7515df2ecfb 100644
--- a/app/views/posts/new.html.erb
+++ b/app/views/posts/new.html.erb
@@ -1,5 +1,30 @@
-<h1 class="has-margin-bottom-2">New Post in <%= @category.name %></h1>
-<p class="has-color-tertiary-500">Not where you meant to post? See <%= link_to 'Categories', categories_path %></p>
+<h1 class="has-margin-bottom-2">
+  New Post
+  <% if @category.present? %>
+    in <%= @category.name %>
+  <% end %>
+</h1>
+<% if @category.present? %>
+  <p class="has-color-tertiary-500">Not where you meant to post? See <%= link_to 'Categories', categories_path %></p>
 
-<%= render 'form', with_post_type: @category.display_post_types.reject { |e| e.to_s.empty? }.size > 1,
-           submit_path: create_post_path(@category.id) %>
\ No newline at end of file
+  <div class="notice is-info">
+    <p><strong>Posting Tips</strong></p>
+    <div class="has-font-size-caption">
+      <% guidance = @category.asking_guidance_override %>
+      <%= raw(sanitize(render_markdown(guidance.present? ? guidance : SiteSetting['AskingGuidance']), scrubber: scrubber)) %>
+    </div>
+  </div>
+<% end %>
+
+<% if @post.errors.any? %>
+  <div class="notice is-danger is-filled">
+    <p>The following errors prevented your post being saved:</p>
+    <ul>
+      <% @post.errors.full_messages.each do |msg| %>
+        <li><%= msg %></li>
+      <% end %>
+    </ul>
+  </div>
+<% end %>
+
+<%= render 'form', post: @post, post_type: @post_type, category: @category, submit_path: request.path %>
\ No newline at end of file
diff --git a/app/views/posts/new_help.html.erb b/app/views/posts/new_help.html.erb
deleted file mode 100644
index a65f5646e2e9fd1f34e36a58b936b6324a62d387..0000000000000000000000000000000000000000
--- a/app/views/posts/new_help.html.erb
+++ /dev/null
@@ -1,55 +0,0 @@
-<% content_for :title, "New Policy Document" %>
-
-<% if @post.errors.any? %>
-  <div class="notice is-danger">
-    These errors prevented this post being saved:
-    <ul>
-      <% @post.errors.full_messages.each do |msg| %>
-        <li><%= msg %></li>
-      <% end %>
-    </ul>
-  </div>
-<% end %>
-
-<h1>New Policy Document</h1>
-<p>
-  If you're a moderator, you may use this page to create help documents that you can link to from /help/&lt;document&gt;.
-  If you're an administrator, you can also create policy documents, including legal documents.
-</p>
-<%= form_for @post, url: create_help_post_path do |f| %>
-  <div class="form-group">
-    <%= f.label :post_type_id, 'Post type', class: "form-element" %>
-    <%= f.select :post_type_id, options_for_select(current_user.is_admin ?
-                                                     [['Policy', PolicyDoc.post_type_id], ['Help', HelpDoc.post_type_id]] :
-                                                     [['Help', HelpDoc.post_type_id]]),
-                 { include_blank: true }, class: 'form-element' %>
-  </div>
-  <div class="form-group">
-    <%= f.label :doc_slug, 'URL slug', class: "form-element" %>
-    <span class="form-caption">In a URL of "https://yoursite.codidact.com/help/topic", the "topic" is the slug.</span>
-    <%= f.text_field :doc_slug, class: 'form-element' %>
-  </div>
-  <div class="form-group">
-    <%= f.label :title, "Title your post:", class: "form-element" %>
-    <%= f.text_field :title, class: "form-element" %>
-  </div>
-  <div class="form-group">
-    <%= f.label :body_markdown, 'Body', class: "form-element" %>
-    <%= f.text_area :body_markdown, { class: "form-element post-field", rows: 15 } %>
-  </div>
-  <div class="form-group">
-    <%= f.label :help_category, 'Category', class: 'form-element' %>
-    <span class="form-caption">
-      Name a category under which to display this post in the help center.
-    </span>
-    <%= f.text_field :help_category, class: 'form-element' %>
-  </div>
-  <div class="form-group">
-    <%= f.label :help_ordering, 'Order', class: 'form-element' %>
-    <span class="form-caption">
-      Control where this post appears in the list of help articles. Higher values appear later in the list.
-    </span>
-    <%= f.number_field :help_ordering, class: 'form-element' %>
-  </div>
-  <%= f.submit "Create", class: "button is-filled is-very-large" %>
-<% end %>
diff --git a/app/views/posts/show.html.erb b/app/views/posts/show.html.erb
new file mode 100644
index 0000000000000000000000000000000000000000..91fc1a3c34a0872af3e45aa922073fcf87be644a
--- /dev/null
+++ b/app/views/posts/show.html.erb
@@ -0,0 +1,52 @@
+<% content_for :title, @post.title.truncate(50) %>
+<% content_for :description do %>
+  <% Rails.cache.fetch "posts/#{@post.id}/description" do %>
+    <%= @post.body_plain[0..74].strip %>...
+    <%= @post.children.any? ? @post.children.first.body_plain[0..74].strip : '' %>
+  <% end %>
+<% end %>
+
+<% content_for :twitter_card_meta do %>
+  <meta name="twitter:card" content="summary" />
+  <% if @post.user.twitter.present? %>
+    <meta name="twitter:creator" content="@<%= @post.user.twitter %>" />
+  <% end %>
+  <meta property="og:url" content="<%= post_url(@post) %>" />
+  <meta property="og:title" content="<%= @post.title %>" />
+  <meta property="og:description" content="<%= @post.body_plain[0..150].strip %>..." />
+  <meta property="og:image" content="<%= "https://#{RequestContext.community.host}#{SiteSetting['SiteLogoPath']}" %>" />
+<% end %>
+
+<%= render 'posts/expanded', post: @post %>
+
+<% if @post.post_type.has_answers %>
+  <h2><%= pluralize(@post.children.where(deleted: false).count, 'answer') %></h2>
+
+  <div class="button-list is-gutterless has-float-right">
+    <a href="<%= query_url(sort: 'score') %>" class="button is-muted is-outlined <%= params[:sort].nil? || params[:sort] == 'score' ? 'is-active' : '' %>">Score</a>
+    <a href="<%= query_url(sort: 'age') %>" class="button is-muted is-outlined <%= params[:sort] == 'age' ? 'is-active' : '' %>">Active</a>
+  </div>
+
+  <div class="has-clear-clear"></div>
+
+  <% @children.each do |answer| %>
+    <%= render 'posts/expanded', post: answer %>
+  <% end %>
+
+  <div class="text-center">
+    <%= will_paginate @children, renderer: BootstrapPagination::Rails %>
+  </div>
+
+  <% if user_signed_in? && !@post.closed %>
+    <h2>Your Answer</h2>
+    <%= render 'posts/form', post: Post.new(post_type_id: Answer.post_type_id, category: @post.category,
+                                            parent: @post), type_summary: false,
+                             category: @post.category, post_type: PostType['Answer'], inline_parent: false,
+                             submit_path: create_response_path(post_type: PostType['Answer'], parent: @post) %>
+  <% elsif !@post.closed %>
+    <p class="has-margin-top-4"></p>
+    <%= link_to new_user_registration_path, class: 'button is-filled is-success h-m-t-4' do %>
+      Sign up to answer this question &raquo;
+    <% end %>
+  <% end %>
+<% end %>
diff --git a/app/views/questions/_form.html.erb b/app/views/questions/_form.html.erb
deleted file mode 100644
index 9b750e5cda2e870130533fccd0ae1b5e5e8ee54f..0000000000000000000000000000000000000000
--- a/app/views/questions/_form.html.erb
+++ /dev/null
@@ -1,52 +0,0 @@
-<% is_edit ||= false %>
-
-<%= render 'posts/markdown_script' %>
-
-<% if @question.errors.any? %>
-  <div class="notice is-danger is-filled">
-    The following errors prevented this post from being saved:
-    <ul>
-      <% @question.errors.full_messages.each do |msg| %>
-        <li><%= msg %></li>
-      <% end %>
-    </ul>
-  </div>
-<% end %>
-
-<%= render 'posts/image_upload' %>
-
-<%= form_for @question, url: is_edit ? edit_question_path(@question) : create_question_path do |f| %>
-  <div class="form-group">
-    <%= f.label :title, "Title your question:", class: "form-element" %>
-    <%= f.text_field :title, class: "form-element" %>
-  </div>
-
-  <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body', post: @question %>
-
-  <div class="post-preview"></div>
-
-  <div class="form-group">
-    <%= f.label :tags_cache, "Tags", class: "form-element" %>
-    <div class="form-caption">
-      Tags help to categorize questions. Separate them by space. Use hyphens for multiple-word tags.
-    </div>
-    <%= f.select :tags_cache, options_for_select(@question.tags_cache.map { |t| [t, t] }, selected: @question.tags_cache),
-                 { include_blank: true }, multiple: true, class: "form-element js-tag-select",
-                 data: { tag_set: @question.category.tag_set.id } %>
-  </div>
-
-  <% if is_edit %>
-    <div class="form-group">
-      <%= label_tag :edit_comment, 'Edit comment', class: "form-element" %>
-      <div class="form-caption">
-        Describe&mdash;if necessary&mdash;what you are changing and why you are making this edit.
-      </div>
-      <%= text_field_tag :edit_comment, params[:edit_comment], class: 'form-element' %>
-    </div>
-  <% end %>
-
-  <div class="form-group">
-    <%= f.submit is_edit ? (check_your_post_privilege(@question, 'edit_posts') ? "Save changes" : "Suggest changes") : 'Ask Question', class: "button is-filled" %>
-    <%= link_to 'Cancel', is_edit ? question_path(@question) : category_path(@question.category), class: 'button is-outlined is-muted' %>
-  </div>
-<% end %>
diff --git a/app/views/questions/edit.html.erb b/app/views/questions/edit.html.erb
deleted file mode 100644
index 11c572979cc528cad38e7dacba10f9f715f42f74..0000000000000000000000000000000000000000
--- a/app/views/questions/edit.html.erb
+++ /dev/null
@@ -1,16 +0,0 @@
-<% content_for :title, "Editing '#{@question.title.truncate(50)}'" %>
-
-<h1><%= check_your_post_privilege(@question, 'edit_posts') ? "Edit Question" : "Suggest Edit for Question" %></h1>
-<%= render 'form', is_edit: true %>
-
-<% if SiteSetting['AskingGuidance'] %>
-  <% content_for :sidebar do %>
-    <div class="widget has-margin-4">
-      <h4 class="widget--header has-margin-0">Hints and Tips</h4>
-      <div class="widget--body">
-      <% guidance = @question.category.asking_guidance_override || SiteSetting['AskingGuidance'] %>
-        <%= raw(sanitize(render_markdown(guidance), scrubber: scrubber)) %>
-      </div>
-    </div>
-  <% end %>
-<% end %>
diff --git a/app/views/questions/index.html.erb b/app/views/questions/index.html.erb
deleted file mode 100644
index 62a3f553378f8b25701a4ecd1b71865d0c28e065..0000000000000000000000000000000000000000
--- a/app/views/questions/index.html.erb
+++ /dev/null
@@ -1,20 +0,0 @@
-<% content_for :title, "Questions" %>
-<% content_for :head do %>
-  <link rel="alternate" type="application/rss+xml" title="Questions RSS Feed" href="/questions/feed.rss">
-<% end %>
-<% content_for :sidebar do %>
-
-<div class="has-margin-4 widget has-border-style-none has-shadow-0">
-  <div class="widget--body has-text-align-right">
-
-    <%= link_to new_question_path, class: 'button is-outlined is-very-large' do %>
-        Ask Question
-    <% end %>
-
-  </div>
-</div>
-<% end %>
-
-<h1>Questions</h1>
-
-<%= render 'list' %>
diff --git a/app/views/questions/lottery.html.erb b/app/views/questions/lottery.html.erb
index 89bc45a4f863e353ed4a1cca620312a430ba718f..4fc8d95197ef988e9efd8edd2e7e8e1fdf31e952 100644
--- a/app/views/questions/lottery.html.erb
+++ b/app/views/questions/lottery.html.erb
@@ -1,16 +1,4 @@
-<% content_for :title, "Questions Lottery" %>
-<% content_for :sidebar do %>
-
-<div class="has-margin-4 widget has-border-style-none has-shadow-0">
-  <div class="widget--body has-text-align-right">
-
-    <%= link_to new_question_path, class: 'button is-filled is-very-large is-outlined' do %>
-        Ask Question
-    <% end %>
-
-  </div>
-</div>
-<% end %>
+<% content_for :title, 'Questions Lottery' %>
 
 <h1>Questions</h1>
 
diff --git a/app/views/questions/meta.html.erb b/app/views/questions/meta.html.erb
deleted file mode 100644
index 203948500c42ebabc7c24e7ef18ceaf9f7e0b201..0000000000000000000000000000000000000000
--- a/app/views/questions/meta.html.erb
+++ /dev/null
@@ -1,17 +0,0 @@
-<% content_for :title, "Meta Questions" %>
-<% content_for :sidebar do %>
-
-<div class="has-margin-4 widget has-border-style-none has-shadow-0">
-  <div class="widget--body has-text-align-right">
-
-    <%= link_to new_meta_question_path, class: 'button is-outlined is-muted is-very-large' do %>
-        Ask Meta Question
-    <% end %>
-
-  </div>
-</div>
-<% end %>
-
-<h1>Meta Questions</h1>
-
-<%= render 'list' %>
diff --git a/app/views/questions/new.html.erb b/app/views/questions/new.html.erb
deleted file mode 100644
index a9484f626b8c25ce48b2500264e1ed18d21aed37..0000000000000000000000000000000000000000
--- a/app/views/questions/new.html.erb
+++ /dev/null
@@ -1,33 +0,0 @@
-<% content_for :title, "Ask a Question" %>
-
-<h1>Ask a Question</h1>
-
-<div class="notice">
-  <i class="fas fa-info-circle"></i> You're asking a new question on the <strong>main site</strong>, which is
-  for questions on the topic of the site. If you want to post feedback or start a discussion about the site itself,
-  you'll need to <%= link_to 'ask a Meta question instead', new_meta_question_path %>.
-</div>
-
-<% if @question.errors.any? %>
-  <div class="notice is-danger">
-    <p>The following <%= "error".pluralize(@question.errors.count) %> prevented the question from being posted:</p>
-    <ul>
-      <% @question.errors.full_messages.each do |e| %>
-        <li><%= e %></li>
-      <% end %>
-    </ul>
-  </div>
-<% end %>
-
-<%= render 'form' %>
-
-<% if SiteSetting['AskingGuidance'] %>
-  <% content_for :sidebar do %>
-    <div class="widget has-margin-4">
-      <h4 class="widget--header has-margin-0">Hints and Tips</h4>
-      <div class="widget--body">
-        <%= raw(sanitize(render_markdown(SiteSetting['AskingGuidance']), scrubber: scrubber)) %>
-      </div>
-    </div>
-  <% end %>
-<% end %>
diff --git a/app/views/questions/new_meta.html.erb b/app/views/questions/new_meta.html.erb
deleted file mode 100644
index 4a7918e72ab38f70c970943c56622a51c7aa111d..0000000000000000000000000000000000000000
--- a/app/views/questions/new_meta.html.erb
+++ /dev/null
@@ -1,35 +0,0 @@
-<% content_for :title, "Ask a Meta Question" %>
-
-<h1>Ask a Meta Question</h1>
-
-<div class="notice">
-  <i class="fas fa-info-circle"></i> You're asking a new question on <strong>Meta</strong>, our area for feedback
-  and discussions about this site itself. If you're intending to ask a topical question on the main site, you're in
-  the wrong place - you can do that <%= link_to 'here instead', new_question_path %>.
-</div>
-
-<% if @question.errors.any? %>
-  <div class="notice is-error">
-    <p>The following <%= "error".pluralize(@question.errors.count) %> prevented the question from being posted:</p>
-    <ul>
-      <% @question.errors.full_messages.each do |e| %>
-        <li><%= e %></li>
-      <% end %>
-    </ul>
-  </div>
-<% end %>
-
-<%= render 'posts/image_upload' %>
-
-<%= render 'form', meta?: true %>
-
-<% if SiteSetting['AskingGuidance'] %>
-  <% content_for :sidebar do %>
-    <div class="widget has-margin-4">
-      <h4 class="widget--header has-margin-0">Hints and Tips</h4>
-      <div class="widget--body">
-        <%= raw(sanitize(render_markdown(SiteSetting['AskingGuidance']), scrubber: scrubber)) %>
-      </div>
-    </div>
-  <% end %>
-<% end %>
diff --git a/app/views/questions/show.html.erb b/app/views/questions/show.html.erb
deleted file mode 100644
index 34eacd7ba04d6a7bcd68dbfcd888f135c47deb19..0000000000000000000000000000000000000000
--- a/app/views/questions/show.html.erb
+++ /dev/null
@@ -1,46 +0,0 @@
-<% content_for :title, @question.title.truncate(50) %>
-<% content_for :description do %>
-  <% Rails.cache.fetch "posts/#{@question.id}/description" do %>
-    <%= @question.body_plain[0..74].strip %>...
-    <%= @answers.any? ? @answers.first.body_plain[0..74].strip : '' %>
-  <% end %>
-<% end %>
-
-<% content_for :twitter_card_meta do %>
-  <meta name="twitter:card" content="summary" />
-  <% if @question.user.twitter.present? %>
-    <meta name="twitter:creator" content="@<%= @question.user.twitter %>" />
-  <% end %>
-  <meta property="og:url" content="<%= question_url(@question) %>" />
-  <meta property="og:title" content="<%= @question.title %>" />
-  <meta property="og:description" content="<%= @question.body_plain[0..150].strip %>..." />
-  <meta property="og:image" content="<%= "https://#{RequestContext.community.host}#{SiteSetting['SiteLogoPath']}" %>" />
-<% end %>
-
-<%= render 'posts/expanded', post: @question %>
-
-<h2><%= pluralize(@question.answers.where(deleted: false).count, "answer") %></h2>
-
-<div class="button-list is-gutterless has-float-right">
-  <a href="<%= query_url(sort: 'score') %>" class="button is-muted is-outlined <%= params[:sort].nil? || params[:sort] == 'score' ? 'is-active' : '' %>">Score</a>
-  <a href="<%= query_url(sort: 'age') %>" class="button is-muted is-outlined <%= params[:sort] == 'age' ? 'is-active' : '' %>">Active</a>
-</div>
-
-<div class="has-clear-clear"></div>
-
-<% @answers.each do |answer| %>
-  <%= render 'posts/expanded', post: answer %>
-<% end %>
-
-<div class="text-center">
-  <%= will_paginate @answers, renderer: BootstrapPagination::Rails %>
-</div>
-
-<% if user_signed_in? && !@question.closed %>
-  <%= render 'answers/new', answer: Answer.new, parent: @question %>
-<% elsif !@question.closed %>
-  <p class="has-margin-top-4"></p>
-  <%= link_to new_user_registration_path, class: 'button is-filled is-success h-m-t-4' do %>
-    Sign up to answer this question &raquo;
-  <% end %>
-<% end %>
diff --git a/app/views/questions/tagged.html.erb b/app/views/questions/tagged.html.erb
deleted file mode 100644
index 2aa7fe81eff2e4dc180ee7291349292ada569157..0000000000000000000000000000000000000000
--- a/app/views/questions/tagged.html.erb
+++ /dev/null
@@ -1,16 +0,0 @@
-<% content_for :title, "Tag: #{params[:tag]}" %>
-
-<h3>Questions tagged <%= params[:tag] %></h3>
-
-<div class="item-list">
-  <% @questions.each do |question| %>
-    <%= render 'posts/type_agnostic', post: question %>
-  <% end %>
-</div><br/>
-
-<%= will_paginate @questions, renderer: BootstrapPagination::Rails %>
-
-<div>
-  <%= link_to 'Subscribe to questions in this tag',
-              new_subscription_path(type: 'tag', qualifier: params[:tag], return_to: request.path) %>
-</div>
diff --git a/app/views/subscription_mailer/subscription.html.erb b/app/views/subscription_mailer/subscription.html.erb
index 399de797299d9a6a517bf3eabc2dc48404aa7c3b..c3ee467ea001fd78ded14b372d7b9bd7a62bb0dc 100644
--- a/app/views/subscription_mailer/subscription.html.erb
+++ b/app/views/subscription_mailer/subscription.html.erb
@@ -8,7 +8,7 @@
 <% @questions.each do |question| %>
   <h3 class="question-title">
     score <%= " +-"[question.score <=> 0] + question.score.to_s %>:
-    <%= link_to question.title, question_url(question, host: @subscription.community.host) %>
+    <%= link_to question.title, post_url(question, host: @subscription.community.host) %>
   </h3>
   <p>
     <%= question.body.first(150).gsub(/<\/?[^>]+>/, '') %>
diff --git a/app/views/subscription_mailer/subscription.text.erb b/app/views/subscription_mailer/subscription.text.erb
index 7c2ddbea5114beec015504edc1324969b7b976ca..c18d661a9263488dc70f87c9c2e76e27fea5c1c3 100644
--- a/app/views/subscription_mailer/subscription.text.erb
+++ b/app/views/subscription_mailer/subscription.text.erb
@@ -3,7 +3,7 @@
 
 <% @questions.each do |question| %>
 score <%= " +-"[question.score <=> 0] + question.score.to_s %>: <%= question.title %>
-<%= question_url(question, host: @subscription.community.host) %>
+<%= post_url(question, host: @subscription.community.host) %>
 
 <%= question.body.first(150).gsub(/<\/?[^>]+>/, '') %><%= question.body.length > 150 ? '...' : '' %>
 — "<%= question.user.rtl_safe_username %>" (<%= user_url(question.user, host: @subscription.community.host) %>),
diff --git a/app/views/subscriptions/new.html.erb b/app/views/subscriptions/new.html.erb
index 9229f92c2410f34be58267eb3b9da112a50cb20d..3f4b7a895e86cb1b2b68fe3736513e990ad5218b 100644
--- a/app/views/subscriptions/new.html.erb
+++ b/app/views/subscriptions/new.html.erb
@@ -17,7 +17,7 @@
     </p>
 
     <p>If you came here from a link and have filled in the details below, this may be a bug. Please let us know on
-      <%= link_to 'Meta', new_meta_question_path %>.</p>
+      <%= link_to 'Meta', 'https://meta.codidact.com/' %>.</p>
   </div>
 <% end %>
 
diff --git a/codeclimate-config.patch b/codeclimate-config.patch
deleted file mode 100644
index 503502907f2c530999296434fc6ec33cdcdb2d71..0000000000000000000000000000000000000000
--- a/codeclimate-config.patch
+++ /dev/null
@@ -1,1576 +0,0 @@
-diff --git a/.codeclimate.yml b/.codeclimate.yml
-index e69de29..81df173 100644
---- a/.codeclimate.yml
-+++ b/.codeclimate.yml
-@@ -0,0 +1,45 @@
-+---
-+engines:
-+  brakeman:
-+    enabled: true
-+  bundler-audit:
-+    enabled: true
-+  csslint:
-+    enabled: true
-+  coffeelint:
-+    enabled: true
-+  duplication:
-+    enabled: true
-+    config:
-+      languages:
-+      - ruby
-+      - javascript
-+      - python
-+      - php
-+  eslint:
-+    enabled: true
-+  fixme:
-+    enabled: true
-+  rubocop:
-+    enabled: true
-+ratings:
-+  paths:
-+  - Gemfile.lock
-+  - "**.erb"
-+  - "**.haml"
-+  - "**.rb"
-+  - "**.rhtml"
-+  - "**.slim"
-+  - "**.css"
-+  - "**.coffee"
-+  - "**.inc"
-+  - "**.js"
-+  - "**.jsx"
-+  - "**.module"
-+  - "**.php"
-+  - "**.py"
-+exclude_paths:
-+- config/
-+- db/
-+- test/
-+- vendor/
-diff --git a/.csslintrc b/.csslintrc
-index e69de29..aacba95 100644
---- a/.csslintrc
-+++ b/.csslintrc
-@@ -0,0 +1,2 @@
-+--exclude-exts=.min.css
-+--ignore=adjoining-classes,box-model,ids,order-alphabetical,unqualified-attributes
-diff --git a/.eslintignore b/.eslintignore
-index e69de29..96212a3 100644
---- a/.eslintignore
-+++ b/.eslintignore
-@@ -0,0 +1 @@
-+**/*{.,-}min.js
-diff --git a/.eslintrc b/.eslintrc
-index e69de29..9faa375 100644
---- a/.eslintrc
-+++ b/.eslintrc
-@@ -0,0 +1,213 @@
-+ecmaFeatures:
-+  modules: true
-+  jsx: true
-+
-+env:
-+  amd: true
-+  browser: true
-+  es6: true
-+  jquery: true
-+  node: true
-+
-+# http://eslint.org/docs/rules/
-+rules:
-+  # Possible Errors
-+  comma-dangle: [2, never]
-+  no-cond-assign: 2
-+  no-console: 0
-+  no-constant-condition: 2
-+  no-control-regex: 2
-+  no-debugger: 2
-+  no-dupe-args: 2
-+  no-dupe-keys: 2
-+  no-duplicate-case: 2
-+  no-empty: 2
-+  no-empty-character-class: 2
-+  no-ex-assign: 2
-+  no-extra-boolean-cast: 2
-+  no-extra-parens: 0
-+  no-extra-semi: 2
-+  no-func-assign: 2
-+  no-inner-declarations: [2, functions]
-+  no-invalid-regexp: 2
-+  no-irregular-whitespace: 2
-+  no-negated-in-lhs: 2
-+  no-obj-calls: 2
-+  no-regex-spaces: 2
-+  no-sparse-arrays: 2
-+  no-unexpected-multiline: 2
-+  no-unreachable: 2
-+  use-isnan: 2
-+  valid-jsdoc: 0
-+  valid-typeof: 2
-+
-+  # Best Practices
-+  accessor-pairs: 2
-+  block-scoped-var: 0
-+  complexity: [2, 6]
-+  consistent-return: 0
-+  curly: 0
-+  default-case: 0
-+  dot-location: 0
-+  dot-notation: 0
-+  eqeqeq: 2
-+  guard-for-in: 2
-+  no-alert: 2
-+  no-caller: 2
-+  no-case-declarations: 2
-+  no-div-regex: 2
-+  no-else-return: 0
-+  no-empty-label: 2
-+  no-empty-pattern: 2
-+  no-eq-null: 2
-+  no-eval: 2
-+  no-extend-native: 2
-+  no-extra-bind: 2
-+  no-fallthrough: 2
-+  no-floating-decimal: 0
-+  no-implicit-coercion: 0
-+  no-implied-eval: 2
-+  no-invalid-this: 0
-+  no-iterator: 2
-+  no-labels: 0
-+  no-lone-blocks: 2
-+  no-loop-func: 2
-+  no-magic-number: 0
-+  no-multi-spaces: 0
-+  no-multi-str: 0
-+  no-native-reassign: 2
-+  no-new-func: 2
-+  no-new-wrappers: 2
-+  no-new: 2
-+  no-octal-escape: 2
-+  no-octal: 2
-+  no-proto: 2
-+  no-redeclare: 2
-+  no-return-assign: 2
-+  no-script-url: 2
-+  no-self-compare: 2
-+  no-sequences: 0
-+  no-throw-literal: 0
-+  no-unused-expressions: 2
-+  no-useless-call: 2
-+  no-useless-concat: 2
-+  no-void: 2
-+  no-warning-comments: 0
-+  no-with: 2
-+  radix: 2
-+  vars-on-top: 0
-+  wrap-iife: 2
-+  yoda: 0
-+
-+  # Strict
-+  strict: 0
-+
-+  # Variables
-+  init-declarations: 0
-+  no-catch-shadow: 2
-+  no-delete-var: 2
-+  no-label-var: 2
-+  no-shadow-restricted-names: 2
-+  no-shadow: 0
-+  no-undef-init: 2
-+  no-undef: 0
-+  no-undefined: 0
-+  no-unused-vars: 0
-+  no-use-before-define: 0
-+
-+  # Node.js and CommonJS
-+  callback-return: 2
-+  global-require: 2
-+  handle-callback-err: 2
-+  no-mixed-requires: 0
-+  no-new-require: 0
-+  no-path-concat: 2
-+  no-process-exit: 2
-+  no-restricted-modules: 0
-+  no-sync: 0
-+
-+  # Stylistic Issues
-+  array-bracket-spacing: 0
-+  block-spacing: 0
-+  brace-style: 0
-+  camelcase: 0
-+  comma-spacing: 0
-+  comma-style: 0
-+  computed-property-spacing: 0
-+  consistent-this: 0
-+  eol-last: 0
-+  func-names: 0
-+  func-style: 0
-+  id-length: 0
-+  id-match: 0
-+  indent: 0
-+  jsx-quotes: 0
-+  key-spacing: 0
-+  linebreak-style: 0
-+  lines-around-comment: 0
-+  max-depth: 0
-+  max-len: 0
-+  max-nested-callbacks: 0
-+  max-params: 0
-+  max-statements: [2, 30]
-+  new-cap: 0
-+  new-parens: 0
-+  newline-after-var: 0
-+  no-array-constructor: 0
-+  no-bitwise: 0
-+  no-continue: 0
-+  no-inline-comments: 0
-+  no-lonely-if: 0
-+  no-mixed-spaces-and-tabs: 0
-+  no-multiple-empty-lines: 0
-+  no-negated-condition: 0
-+  no-nested-ternary: 0
-+  no-new-object: 0
-+  no-plusplus: 0
-+  no-restricted-syntax: 0
-+  no-spaced-func: 0
-+  no-ternary: 0
-+  no-trailing-spaces: 0
-+  no-underscore-dangle: 0
-+  no-unneeded-ternary: 0
-+  object-curly-spacing: 0
-+  one-var: 0
-+  operator-assignment: 0
-+  operator-linebreak: 0
-+  padded-blocks: 0
-+  quote-props: 0
-+  quotes: 0
-+  require-jsdoc: 0
-+  semi-spacing: 0
-+  semi: 0
-+  sort-vars: 0
-+  space-after-keywords: 0
-+  space-before-blocks: 0
-+  space-before-function-paren: 0
-+  space-before-keywords: 0
-+  space-in-parens: 0
-+  space-infix-ops: 0
-+  space-return-throw-case: 0
-+  space-unary-ops: 0
-+  spaced-comment: 0
-+  wrap-regex: 0
-+
-+  # ECMAScript 6
-+  arrow-body-style: 0
-+  arrow-parens: 0
-+  arrow-spacing: 0
-+  constructor-super: 0
-+  generator-star-spacing: 0
-+  no-arrow-condition: 0
-+  no-class-assign: 0
-+  no-const-assign: 0
-+  no-dupe-class-members: 0
-+  no-this-before-super: 0
-+  no-var: 0
-+  object-shorthand: 0
-+  prefer-arrow-callback: 0
-+  prefer-const: 0
-+  prefer-reflect: 0
-+  prefer-spread: 0
-+  prefer-template: 0
-+  require-yield: 0
-diff --git a/.rubocop.yml b/.rubocop.yml
-index e69de29..3f1d222 100644
---- a/.rubocop.yml
-+++ b/.rubocop.yml
-@@ -0,0 +1,1156 @@
-+AllCops:
-+  DisabledByDefault: true
-+
-+#################### Lint ################################
-+
-+Lint/AmbiguousOperator:
-+  Description: >-
-+                 Checks for ambiguous operators in the first argument of a
-+                 method invocation without parentheses.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#parens-as-args'
-+  Enabled: true
-+
-+Lint/AmbiguousRegexpLiteral:
-+  Description: >-
-+                 Checks for ambiguous regexp literals in the first argument of
-+                 a method invocation without parenthesis.
-+  Enabled: true
-+
-+Lint/AssignmentInCondition:
-+  Description: "Don't use assignment in conditions."
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#safe-assignment-in-condition'
-+  Enabled: true
-+
-+Lint/BlockAlignment:
-+  Description: 'Align block ends correctly.'
-+  Enabled: true
-+
-+Lint/CircularArgumentReference:
-+  Description: "Don't refer to the keyword argument in the default value."
-+  Enabled: true
-+
-+Lint/ConditionPosition:
-+  Description: >-
-+                 Checks for condition placed in a confusing position relative to
-+                 the keyword.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#same-line-condition'
-+  Enabled: true
-+
-+Lint/Debugger:
-+  Description: 'Check for debugger calls.'
-+  Enabled: true
-+
-+Lint/DefEndAlignment:
-+  Description: 'Align ends corresponding to defs correctly.'
-+  Enabled: true
-+
-+Lint/DeprecatedClassMethods:
-+  Description: 'Check for deprecated class method calls.'
-+  Enabled: true
-+
-+Lint/DuplicateMethods:
-+  Description: 'Check for duplicate methods calls.'
-+  Enabled: true
-+
-+Lint/EachWithObjectArgument:
-+  Description: 'Check for immutable argument given to each_with_object.'
-+  Enabled: true
-+
-+Lint/ElseLayout:
-+  Description: 'Check for odd code arrangement in an else block.'
-+  Enabled: true
-+
-+Lint/EmptyEnsure:
-+  Description: 'Checks for empty ensure block.'
-+  Enabled: true
-+
-+Lint/EmptyInterpolation:
-+  Description: 'Checks for empty string interpolation.'
-+  Enabled: true
-+
-+Lint/EndAlignment:
-+  Description: 'Align ends correctly.'
-+  Enabled: true
-+
-+Lint/EndInMethod:
-+  Description: 'END blocks should not be placed inside method definitions.'
-+  Enabled: true
-+
-+Lint/EnsureReturn:
-+  Description: 'Do not use return in an ensure block.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-return-ensure'
-+  Enabled: true
-+
-+Lint/Eval:
-+  Description: 'The use of eval represents a serious security risk.'
-+  Enabled: true
-+
-+Lint/FormatParameterMismatch:
-+  Description: 'The number of parameters to format/sprint must match the fields.'
-+  Enabled: true
-+
-+Lint/HandleExceptions:
-+  Description: "Don't suppress exception."
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#dont-hide-exceptions'
-+  Enabled: true
-+
-+Lint/InvalidCharacterLiteral:
-+  Description: >-
-+                 Checks for invalid character literals with a non-escaped
-+                 whitespace character.
-+  Enabled: true
-+
-+Lint/LiteralInCondition:
-+  Description: 'Checks of literals used in conditions.'
-+  Enabled: true
-+
-+Lint/LiteralInInterpolation:
-+  Description: 'Checks for literals used in interpolation.'
-+  Enabled: true
-+
-+Lint/Loop:
-+  Description: >-
-+                 Use Kernel#loop with break rather than begin/end/until or
-+                 begin/end/while for post-loop tests.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#loop-with-break'
-+  Enabled: true
-+
-+Lint/NestedMethodDefinition:
-+  Description: 'Do not use nested method definitions.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-nested-methods'
-+  Enabled: true
-+
-+Lint/NonLocalExitFromIterator:
-+  Description: 'Do not use return in iterator to cause non-local exit.'
-+  Enabled: true
-+
-+Lint/ParenthesesAsGroupedExpression:
-+  Description: >-
-+                 Checks for method calls with a space before the opening
-+                 parenthesis.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#parens-no-spaces'
-+  Enabled: true
-+
-+Lint/RequireParentheses:
-+  Description: >-
-+                 Use parentheses in the method call to avoid confusion
-+                 about precedence.
-+  Enabled: true
-+
-+Lint/RescueException:
-+  Description: 'Avoid rescuing the Exception class.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-blind-rescues'
-+  Enabled: true
-+
-+Lint/ShadowingOuterLocalVariable:
-+  Description: >-
-+                 Do not use the same name as outer local variable
-+                 for block arguments or block local variables.
-+  Enabled: true
-+
-+Lint/StringConversionInInterpolation:
-+  Description: 'Checks for Object#to_s usage in string interpolation.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-to-s'
-+  Enabled: true
-+
-+Lint/UnderscorePrefixedVariableName:
-+  Description: 'Do not use prefix `_` for a variable that is used.'
-+  Enabled: true
-+
-+Lint/UnneededDisable:
-+  Description: >-
-+                 Checks for rubocop:disable comments that can be removed.
-+                 Note: this cop is not disabled when disabling all cops.
-+                 It must be explicitly disabled.
-+  Enabled: true
-+
-+Lint/UnusedBlockArgument:
-+  Description: 'Checks for unused block arguments.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#underscore-unused-vars'
-+  Enabled: true
-+
-+Lint/UnusedMethodArgument:
-+  Description: 'Checks for unused method arguments.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#underscore-unused-vars'
-+  Enabled: true
-+
-+Lint/UnreachableCode:
-+  Description: 'Unreachable code.'
-+  Enabled: true
-+
-+Lint/UselessAccessModifier:
-+  Description: 'Checks for useless access modifiers.'
-+  Enabled: true
-+
-+Lint/UselessAssignment:
-+  Description: 'Checks for useless assignment to a local variable.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#underscore-unused-vars'
-+  Enabled: true
-+
-+Lint/UselessComparison:
-+  Description: 'Checks for comparison of something with itself.'
-+  Enabled: true
-+
-+Lint/UselessElseWithoutRescue:
-+  Description: 'Checks for useless `else` in `begin..end` without `rescue`.'
-+  Enabled: true
-+
-+Lint/UselessSetterCall:
-+  Description: 'Checks for useless setter call to a local variable.'
-+  Enabled: true
-+
-+Lint/Void:
-+  Description: 'Possible use of operator/literal/variable in void context.'
-+  Enabled: true
-+
-+###################### Metrics ####################################
-+
-+Metrics/AbcSize:
-+  Description: >-
-+                 A calculated magnitude based on number of assignments,
-+                 branches, and conditions.
-+  Reference: 'http://c2.com/cgi/wiki?AbcMetric'
-+  Enabled: false
-+  Max: 20
-+
-+Metrics/BlockNesting:
-+  Description: 'Avoid excessive block nesting'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#three-is-the-number-thou-shalt-count'
-+  Enabled: true
-+  Max: 4
-+
-+Metrics/ClassLength:
-+  Description: 'Avoid classes longer than 250 lines of code.'
-+  Enabled: true
-+  Max: 250
-+
-+Metrics/CyclomaticComplexity:
-+  Description: >-
-+                 A complexity metric that is strongly correlated to the number
-+                 of test cases needed to validate a method.
-+  Enabled: true
-+
-+Metrics/LineLength:
-+  Description: 'Limit lines to 80 characters.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#80-character-limits'
-+  Enabled: false
-+
-+Metrics/MethodLength:
-+  Description: 'Avoid methods longer than 30 lines of code.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#short-methods'
-+  Enabled: true
-+  Max: 30
-+
-+Metrics/ModuleLength:
-+  Description: 'Avoid modules longer than 250 lines of code.'
-+  Enabled: true
-+  Max: 250
-+
-+Metrics/ParameterLists:
-+  Description: 'Avoid parameter lists longer than three or four parameters.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#too-many-params'
-+  Enabled: true
-+
-+Metrics/PerceivedComplexity:
-+  Description: >-
-+                 A complexity metric geared towards measuring complexity for a
-+                 human reader.
-+  Enabled: false
-+
-+##################### Performance #############################
-+
-+Performance/Count:
-+  Description: >-
-+                  Use `count` instead of `select...size`, `reject...size`,
-+                  `select...count`, `reject...count`, `select...length`,
-+                  and `reject...length`.
-+  Enabled: true
-+
-+Performance/Detect:
-+  Description: >-
-+                  Use `detect` instead of `select.first`, `find_all.first`,
-+                  `select.last`, and `find_all.last`.
-+  Reference: 'https://github.com/JuanitoFatas/fast-ruby#enumerabledetect-vs-enumerableselectfirst-code'
-+  Enabled: true
-+
-+Performance/FlatMap:
-+  Description: >-
-+                  Use `Enumerable#flat_map`
-+                  instead of `Enumerable#map...Array#flatten(1)`
-+                  or `Enumberable#collect..Array#flatten(1)`
-+  Reference: 'https://github.com/JuanitoFatas/fast-ruby#enumerablemaparrayflatten-vs-enumerableflat_map-code'
-+  Enabled: true
-+  EnabledForFlattenWithoutParams: false
-+  # If enabled, this cop will warn about usages of
-+  # `flatten` being called without any parameters.
-+  # This can be dangerous since `flat_map` will only flatten 1 level, and
-+  # `flatten` without any parameters can flatten multiple levels.
-+
-+Performance/ReverseEach:
-+  Description: 'Use `reverse_each` instead of `reverse.each`.'
-+  Reference: 'https://github.com/JuanitoFatas/fast-ruby#enumerablereverseeach-vs-enumerablereverse_each-code'
-+  Enabled: true
-+
-+Performance/Sample:
-+  Description: >-
-+                  Use `sample` instead of `shuffle.first`,
-+                  `shuffle.last`, and `shuffle[Fixnum]`.
-+  Reference: 'https://github.com/JuanitoFatas/fast-ruby#arrayshufflefirst-vs-arraysample-code'
-+  Enabled: true
-+
-+Performance/Size:
-+  Description: >-
-+                  Use `size` instead of `count` for counting
-+                  the number of elements in `Array` and `Hash`.
-+  Reference: 'https://github.com/JuanitoFatas/fast-ruby#arraycount-vs-arraysize-code'
-+  Enabled: true
-+
-+Performance/StringReplacement:
-+  Description: >-
-+                  Use `tr` instead of `gsub` when you are replacing the same
-+                  number of characters. Use `delete` instead of `gsub` when
-+                  you are deleting characters.
-+  Reference: 'https://github.com/JuanitoFatas/fast-ruby#stringgsub-vs-stringtr-code'
-+  Enabled: true
-+
-+##################### Rails ##################################
-+
-+Rails/ActionFilter:
-+  Description: 'Enforces consistent use of action filter methods.'
-+  Enabled: false
-+
-+Rails/Date:
-+  Description: >-
-+                  Checks the correct usage of date aware methods,
-+                  such as Date.today, Date.current etc.
-+  Enabled: false
-+
-+Rails/Delegate:
-+  Description: 'Prefer delegate method for delegations.'
-+  Enabled: false
-+
-+Rails/FindBy:
-+  Description: 'Prefer find_by over where.first.'
-+  Enabled: false
-+
-+Rails/FindEach:
-+  Description: 'Prefer all.find_each over all.find.'
-+  Enabled: false
-+
-+Rails/HasAndBelongsToMany:
-+  Description: 'Prefer has_many :through to has_and_belongs_to_many.'
-+  Enabled: false
-+
-+Rails/Output:
-+  Description: 'Checks for calls to puts, print, etc.'
-+  Enabled: false
-+
-+Rails/ReadWriteAttribute:
-+  Description: >-
-+                 Checks for read_attribute(:attr) and
-+                 write_attribute(:attr, val).
-+  Enabled: false
-+
-+Rails/ScopeArgs:
-+  Description: 'Checks the arguments of ActiveRecord scopes.'
-+  Enabled: false
-+
-+Rails/TimeZone:
-+  Description: 'Checks the correct usage of time zone aware methods.'
-+  StyleGuide: 'https://github.com/bbatsov/rails-style-guide#time'
-+  Reference: 'http://danilenko.org/2012/7/6/rails_timezones'
-+  Enabled: false
-+
-+Rails/Validation:
-+  Description: 'Use validates :attribute, hash of validations.'
-+  Enabled: false
-+
-+################## Style #################################
-+
-+Style/AccessModifierIndentation:
-+  Description: Check indentation of private/protected visibility modifiers.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#indent-public-private-protected'
-+  Enabled: false
-+
-+Style/AccessorMethodName:
-+  Description: Check the naming of accessor methods for get_/set_.
-+  Enabled: false
-+
-+Style/Alias:
-+  Description: 'Use alias_method instead of alias.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#alias-method'
-+  Enabled: false
-+
-+Style/AlignArray:
-+  Description: >-
-+                 Align the elements of an array literal if they span more than
-+                 one line.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#align-multiline-arrays'
-+  Enabled: false
-+
-+Style/AlignHash:
-+  Description: >-
-+                 Align the elements of a hash literal if they span more than
-+                 one line.
-+  Enabled: false
-+
-+Style/AlignParameters:
-+  Description: >-
-+                 Align the parameters of a method call if they span more
-+                 than one line.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-double-indent'
-+  Enabled: false
-+
-+Style/AndOr:
-+  Description: 'Use &&/|| instead of and/or.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-and-or-or'
-+  Enabled: false
-+
-+Style/ArrayJoin:
-+  Description: 'Use Array#join instead of Array#*.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#array-join'
-+  Enabled: false
-+
-+Style/AsciiComments:
-+  Description: 'Use only ascii symbols in comments.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#english-comments'
-+  Enabled: false
-+
-+Style/AsciiIdentifiers:
-+  Description: 'Use only ascii symbols in identifiers.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#english-identifiers'
-+  Enabled: false
-+
-+Style/Attr:
-+  Description: 'Checks for uses of Module#attr.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#attr'
-+  Enabled: false
-+
-+Style/BeginBlock:
-+  Description: 'Avoid the use of BEGIN blocks.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-BEGIN-blocks'
-+  Enabled: false
-+
-+Style/BarePercentLiterals:
-+  Description: 'Checks if usage of %() or %Q() matches configuration.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#percent-q-shorthand'
-+  Enabled: false
-+
-+Style/BlockComments:
-+  Description: 'Do not use block comments.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-block-comments'
-+  Enabled: false
-+
-+Style/BlockEndNewline:
-+  Description: 'Put end statement of multiline block on its own line.'
-+  Enabled: false
-+
-+Style/BlockDelimiters:
-+  Description: >-
-+                Avoid using {...} for multi-line blocks (multiline chaining is
-+                always ugly).
-+                Prefer {...} over do...end for single-line blocks.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#single-line-blocks'
-+  Enabled: false
-+
-+Style/BracesAroundHashParameters:
-+  Description: 'Enforce braces style around hash parameters.'
-+  Enabled: false
-+
-+Style/CaseEquality:
-+  Description: 'Avoid explicit use of the case equality operator(===).'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-case-equality'
-+  Enabled: false
-+
-+Style/CaseIndentation:
-+  Description: 'Indentation of when in a case/when/[else/]end.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#indent-when-to-case'
-+  Enabled: false
-+
-+Style/CharacterLiteral:
-+  Description: 'Checks for uses of character literals.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-character-literals'
-+  Enabled: false
-+
-+Style/ClassAndModuleCamelCase:
-+  Description: 'Use CamelCase for classes and modules.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#camelcase-classes'
-+  Enabled: false
-+
-+Style/ClassAndModuleChildren:
-+  Description: 'Checks style of children classes and modules.'
-+  Enabled: false
-+
-+Style/ClassCheck:
-+  Description: 'Enforces consistent use of `Object#is_a?` or `Object#kind_of?`.'
-+  Enabled: false
-+
-+Style/ClassMethods:
-+  Description: 'Use self when defining module/class methods.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#def-self-class-methods'
-+  Enabled: false
-+
-+Style/ClassVars:
-+  Description: 'Avoid the use of class variables.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-class-vars'
-+  Enabled: false
-+
-+Style/ClosingParenthesisIndentation:
-+  Description: 'Checks the indentation of hanging closing parentheses.'
-+  Enabled: false
-+
-+Style/ColonMethodCall:
-+  Description: 'Do not use :: for method call.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#double-colons'
-+  Enabled: false
-+
-+Style/CommandLiteral:
-+  Description: 'Use `` or %x around command literals.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#percent-x'
-+  Enabled: false
-+
-+Style/CommentAnnotation:
-+  Description: 'Checks formatting of annotation comments.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#annotate-keywords'
-+  Enabled: false
-+
-+Style/CommentIndentation:
-+  Description: 'Indentation of comments.'
-+  Enabled: false
-+
-+Style/ConstantName:
-+  Description: 'Constants should use SCREAMING_SNAKE_CASE.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#screaming-snake-case'
-+  Enabled: false
-+
-+Style/DefWithParentheses:
-+  Description: 'Use def with parentheses when there are arguments.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#method-parens'
-+  Enabled: false
-+
-+Style/DeprecatedHashMethods:
-+  Description: 'Checks for use of deprecated Hash methods.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#hash-key'
-+  Enabled: false
-+
-+Style/Documentation:
-+  Description: 'Document classes and non-namespace modules.'
-+  Enabled: false
-+
-+Style/DotPosition:
-+  Description: 'Checks the position of the dot in multi-line method calls.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#consistent-multi-line-chains'
-+  Enabled: false
-+
-+Style/DoubleNegation:
-+  Description: 'Checks for uses of double negation (!!).'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-bang-bang'
-+  Enabled: false
-+
-+Style/EachWithObject:
-+  Description: 'Prefer `each_with_object` over `inject` or `reduce`.'
-+  Enabled: false
-+
-+Style/ElseAlignment:
-+  Description: 'Align elses and elsifs correctly.'
-+  Enabled: false
-+
-+Style/EmptyElse:
-+  Description: 'Avoid empty else-clauses.'
-+  Enabled: false
-+
-+Style/EmptyLineBetweenDefs:
-+  Description: 'Use empty lines between defs.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#empty-lines-between-methods'
-+  Enabled: false
-+
-+Style/EmptyLines:
-+  Description: "Don't use several empty lines in a row."
-+  Enabled: false
-+
-+Style/EmptyLinesAroundAccessModifier:
-+  Description: "Keep blank lines around access modifiers."
-+  Enabled: false
-+
-+Style/EmptyLinesAroundBlockBody:
-+  Description: "Keeps track of empty lines around block bodies."
-+  Enabled: false
-+
-+Style/EmptyLinesAroundClassBody:
-+  Description: "Keeps track of empty lines around class bodies."
-+  Enabled: false
-+
-+Style/EmptyLinesAroundModuleBody:
-+  Description: "Keeps track of empty lines around module bodies."
-+  Enabled: false
-+
-+Style/EmptyLinesAroundMethodBody:
-+  Description: "Keeps track of empty lines around method bodies."
-+  Enabled: false
-+
-+Style/EmptyLiteral:
-+  Description: 'Prefer literals to Array.new/Hash.new/String.new.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#literal-array-hash'
-+  Enabled: false
-+
-+Style/EndBlock:
-+  Description: 'Avoid the use of END blocks.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-END-blocks'
-+  Enabled: false
-+
-+Style/EndOfLine:
-+  Description: 'Use Unix-style line endings.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#crlf'
-+  Enabled: false
-+
-+Style/EvenOdd:
-+  Description: 'Favor the use of Fixnum#even? && Fixnum#odd?'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#predicate-methods'
-+  Enabled: false
-+
-+Style/ExtraSpacing:
-+  Description: 'Do not use unnecessary spacing.'
-+  Enabled: false
-+
-+Style/FileName:
-+  Description: 'Use snake_case for source file names.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#snake-case-files'
-+  Enabled: false
-+
-+Style/InitialIndentation:
-+  Description: >-
-+    Checks the indentation of the first non-blank non-comment line in a file.
-+  Enabled: false
-+
-+Style/FirstParameterIndentation:
-+  Description: 'Checks the indentation of the first parameter in a method call.'
-+  Enabled: false
-+
-+Style/FlipFlop:
-+  Description: 'Checks for flip flops'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-flip-flops'
-+  Enabled: false
-+
-+Style/For:
-+  Description: 'Checks use of for or each in multiline loops.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-for-loops'
-+  Enabled: false
-+
-+Style/FormatString:
-+  Description: 'Enforce the use of Kernel#sprintf, Kernel#format or String#%.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#sprintf'
-+  Enabled: false
-+
-+Style/GlobalVars:
-+  Description: 'Do not introduce global variables.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#instance-vars'
-+  Reference: 'http://www.zenspider.com/Languages/Ruby/QuickRef.html'
-+  Enabled: false
-+
-+Style/GuardClause:
-+  Description: 'Check for conditionals that can be replaced with guard clauses'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-nested-conditionals'
-+  Enabled: false
-+
-+Style/HashSyntax:
-+  Description: >-
-+                 Prefer Ruby 1.9 hash syntax { a: 1, b: 2 } over 1.8 syntax
-+                 { a: 1, b: 2 }.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#hash-literals'
-+  Enabled: false
-+
-+Style/IfUnlessModifier:
-+  Description: >-
-+                 Favor modifier if/unless usage when you have a
-+                 single-line body.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#if-as-a-modifier'
-+  Enabled: false
-+
-+Style/IfWithSemicolon:
-+  Description: 'Do not use if x; .... Use the ternary operator instead.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-semicolon-ifs'
-+  Enabled: false
-+
-+Style/IndentationConsistency:
-+  Description: 'Keep indentation straight.'
-+  Enabled: false
-+
-+Style/IndentationWidth:
-+  Description: 'Use 2 spaces for indentation.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#spaces-indentation'
-+  Enabled: false
-+
-+Style/IndentArray:
-+  Description: >-
-+                 Checks the indentation of the first element in an array
-+                 literal.
-+  Enabled: false
-+
-+Style/IndentHash:
-+  Description: 'Checks the indentation of the first key in a hash literal.'
-+  Enabled: false
-+
-+Style/InfiniteLoop:
-+  Description: 'Use Kernel#loop for infinite loops.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#infinite-loop'
-+  Enabled: false
-+
-+Style/Lambda:
-+  Description: 'Use the new lambda literal syntax for single-line blocks.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#lambda-multi-line'
-+  Enabled: false
-+
-+Style/LambdaCall:
-+  Description: 'Use lambda.call(...) instead of lambda.(...).'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#proc-call'
-+  Enabled: false
-+
-+Style/LeadingCommentSpace:
-+  Description: 'Comments should start with a space.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#hash-space'
-+  Enabled: false
-+
-+Style/LineEndConcatenation:
-+  Description: >-
-+                 Use \ instead of + or << to concatenate two string literals at
-+                 line end.
-+  Enabled: false
-+
-+Style/MethodCallParentheses:
-+  Description: 'Do not use parentheses for method calls with no arguments.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-args-no-parens'
-+  Enabled: false
-+
-+Style/MethodDefParentheses:
-+  Description: >-
-+                 Checks if the method definitions have or don't have
-+                 parentheses.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#method-parens'
-+  Enabled: false
-+
-+Style/MethodName:
-+  Description: 'Use the configured style when naming methods.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#snake-case-symbols-methods-vars'
-+  Enabled: false
-+
-+Style/ModuleFunction:
-+  Description: 'Checks for usage of `extend self` in modules.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#module-function'
-+  Enabled: false
-+
-+Style/MultilineBlockChain:
-+  Description: 'Avoid multi-line chains of blocks.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#single-line-blocks'
-+  Enabled: false
-+
-+Style/MultilineBlockLayout:
-+  Description: 'Ensures newlines after multiline block do statements.'
-+  Enabled: false
-+
-+Style/MultilineIfThen:
-+  Description: 'Do not use then for multi-line if/unless.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-then'
-+  Enabled: false
-+
-+Style/MultilineOperationIndentation:
-+  Description: >-
-+                 Checks indentation of binary operations that span more than
-+                 one line.
-+  Enabled: false
-+
-+Style/MultilineTernaryOperator:
-+  Description: >-
-+                 Avoid multi-line ?: (the ternary operator);
-+                 use if/unless instead.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-multiline-ternary'
-+  Enabled: false
-+
-+Style/NegatedIf:
-+  Description: >-
-+                 Favor unless over if for negative conditions
-+                 (or control flow or).
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#unless-for-negatives'
-+  Enabled: false
-+
-+Style/NegatedWhile:
-+  Description: 'Favor until over while for negative conditions.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#until-for-negatives'
-+  Enabled: false
-+
-+Style/NestedTernaryOperator:
-+  Description: 'Use one expression per branch in a ternary operator.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-nested-ternary'
-+  Enabled: false
-+
-+Style/Next:
-+  Description: 'Use `next` to skip iteration instead of a condition at the end.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-nested-conditionals'
-+  Enabled: false
-+
-+Style/NilComparison:
-+  Description: 'Prefer x.nil? to x == nil.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#predicate-methods'
-+  Enabled: false
-+
-+Style/NonNilCheck:
-+  Description: 'Checks for redundant nil checks.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-non-nil-checks'
-+  Enabled: false
-+
-+Style/Not:
-+  Description: 'Use ! instead of not.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#bang-not-not'
-+  Enabled: false
-+
-+Style/NumericLiterals:
-+  Description: >-
-+                 Add underscores to large numeric literals to improve their
-+                 readability.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#underscores-in-numerics'
-+  Enabled: false
-+
-+Style/OneLineConditional:
-+  Description: >-
-+                 Favor the ternary operator(?:) over
-+                 if/then/else/end constructs.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#ternary-operator'
-+  Enabled: false
-+
-+Style/OpMethod:
-+  Description: 'When defining binary operators, name the argument other.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#other-arg'
-+  Enabled: false
-+
-+Style/OptionalArguments:
-+  Description: >-
-+                 Checks for optional arguments that do not appear at the end
-+                 of the argument list
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#optional-arguments'
-+  Enabled: false
-+
-+Style/ParallelAssignment:
-+  Description: >-
-+                  Check for simple usages of parallel assignment.
-+                  It will only warn when the number of variables
-+                  matches on both sides of the assignment.
-+                  This also provides performance benefits
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#parallel-assignment'
-+  Enabled: false
-+
-+Style/ParenthesesAroundCondition:
-+  Description: >-
-+                 Don't use parentheses around the condition of an
-+                 if/unless/while.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-parens-if'
-+  Enabled: false
-+
-+Style/PercentLiteralDelimiters:
-+  Description: 'Use `%`-literal delimiters consistently'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#percent-literal-braces'
-+  Enabled: false
-+
-+Style/PercentQLiterals:
-+  Description: 'Checks if uses of %Q/%q match the configured preference.'
-+  Enabled: false
-+
-+Style/PerlBackrefs:
-+  Description: 'Avoid Perl-style regex back references.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-perl-regexp-last-matchers'
-+  Enabled: false
-+
-+Style/PredicateName:
-+  Description: 'Check the names of predicate methods.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#bool-methods-qmark'
-+  Enabled: false
-+
-+Style/Proc:
-+  Description: 'Use proc instead of Proc.new.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#proc'
-+  Enabled: false
-+
-+Style/RaiseArgs:
-+  Description: 'Checks the arguments passed to raise/fail.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#exception-class-messages'
-+  Enabled: false
-+
-+Style/RedundantBegin:
-+  Description: "Don't use begin blocks when they are not needed."
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#begin-implicit'
-+  Enabled: false
-+
-+Style/RedundantException:
-+  Description: "Checks for an obsolete RuntimeException argument in raise/fail."
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-explicit-runtimeerror'
-+  Enabled: false
-+
-+Style/RedundantReturn:
-+  Description: "Don't use return where it's not required."
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-explicit-return'
-+  Enabled: false
-+
-+Style/RedundantSelf:
-+  Description: "Don't use self where it's not needed."
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-self-unless-required'
-+  Enabled: false
-+
-+Style/RegexpLiteral:
-+  Description: 'Use / or %r around regular expressions.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#percent-r'
-+  Enabled: false
-+
-+Style/RescueEnsureAlignment:
-+  Description: 'Align rescues and ensures correctly.'
-+  Enabled: false
-+
-+Style/RescueModifier:
-+  Description: 'Avoid using rescue in its modifier form.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-rescue-modifiers'
-+  Enabled: false
-+
-+Style/SelfAssignment:
-+  Description: >-
-+                 Checks for places where self-assignment shorthand should have
-+                 been used.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#self-assignment'
-+  Enabled: false
-+
-+Style/Semicolon:
-+  Description: "Don't use semicolons to terminate expressions."
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-semicolon'
-+  Enabled: false
-+
-+Style/SignalException:
-+  Description: 'Checks for proper usage of fail and raise.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#fail-method'
-+  Enabled: false
-+
-+Style/SingleLineBlockParams:
-+  Description: 'Enforces the names of some block params.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#reduce-blocks'
-+  Enabled: false
-+
-+Style/SingleLineMethods:
-+  Description: 'Avoid single-line methods.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-single-line-methods'
-+  Enabled: false
-+
-+Style/SpaceBeforeFirstArg:
-+  Description: >-
-+                 Checks that exactly one space is used between a method name
-+                 and the first argument for method calls without parentheses.
-+  Enabled: true
-+
-+Style/SpaceAfterColon:
-+  Description: 'Use spaces after colons.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#spaces-operators'
-+  Enabled: false
-+
-+Style/SpaceAfterComma:
-+  Description: 'Use spaces after commas.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#spaces-operators'
-+  Enabled: false
-+
-+Style/SpaceAroundKeyword:
-+  Description: 'Use spaces around keywords.'
-+  Enabled: false
-+
-+Style/SpaceAfterMethodName:
-+  Description: >-
-+                 Do not put a space between a method name and the opening
-+                 parenthesis in a method definition.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#parens-no-spaces'
-+  Enabled: false
-+
-+Style/SpaceAfterNot:
-+  Description: Tracks redundant space after the ! operator.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-space-bang'
-+  Enabled: false
-+
-+Style/SpaceAfterSemicolon:
-+  Description: 'Use spaces after semicolons.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#spaces-operators'
-+  Enabled: false
-+
-+Style/SpaceBeforeBlockBraces:
-+  Description: >-
-+                 Checks that the left block brace has or doesn't have space
-+                 before it.
-+  Enabled: false
-+
-+Style/SpaceBeforeComma:
-+  Description: 'No spaces before commas.'
-+  Enabled: false
-+
-+Style/SpaceBeforeComment:
-+  Description: >-
-+                 Checks for missing space between code and a comment on the
-+                 same line.
-+  Enabled: false
-+
-+Style/SpaceBeforeSemicolon:
-+  Description: 'No spaces before semicolons.'
-+  Enabled: false
-+
-+Style/SpaceInsideBlockBraces:
-+  Description: >-
-+                 Checks that block braces have or don't have surrounding space.
-+                 For blocks taking parameters, checks that the left brace has
-+                 or doesn't have trailing space.
-+  Enabled: false
-+
-+Style/SpaceAroundBlockParameters:
-+  Description: 'Checks the spacing inside and after block parameters pipes.'
-+  Enabled: false
-+
-+Style/SpaceAroundEqualsInParameterDefault:
-+  Description: >-
-+                 Checks that the equals signs in parameter default assignments
-+                 have or don't have surrounding space depending on
-+                 configuration.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#spaces-around-equals'
-+  Enabled: false
-+
-+Style/SpaceAroundOperators:
-+  Description: 'Use a single space around operators.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#spaces-operators'
-+  Enabled: false
-+
-+Style/SpaceInsideBrackets:
-+  Description: 'No spaces after [ or before ].'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-spaces-braces'
-+  Enabled: false
-+
-+Style/SpaceInsideHashLiteralBraces:
-+  Description: "Use spaces inside hash literal braces - or don't."
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#spaces-operators'
-+  Enabled: false
-+
-+Style/SpaceInsideParens:
-+  Description: 'No spaces after ( or before ).'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-spaces-braces'
-+  Enabled: false
-+
-+Style/SpaceInsideRangeLiteral:
-+  Description: 'No spaces inside range literals.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-space-inside-range-literals'
-+  Enabled: false
-+
-+Style/SpaceInsideStringInterpolation:
-+  Description: 'Checks for padding/surrounding spaces inside string interpolation.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#string-interpolation'
-+  Enabled: false
-+
-+Style/SpecialGlobalVars:
-+  Description: 'Avoid Perl-style global variables.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-cryptic-perlisms'
-+  Enabled: false
-+
-+Style/StringLiterals:
-+  Description: 'Checks if uses of quotes match the configured preference.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#consistent-string-literals'
-+  Enabled: false
-+
-+Style/StringLiteralsInInterpolation:
-+  Description: >-
-+                 Checks if uses of quotes inside expressions in interpolated
-+                 strings match the configured preference.
-+  Enabled: false
-+
-+Style/StructInheritance:
-+  Description: 'Checks for inheritance from Struct.new.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-extend-struct-new'
-+  Enabled: false
-+
-+Style/SymbolLiteral:
-+  Description: 'Use plain symbols instead of string symbols when possible.'
-+  Enabled: false
-+
-+Style/SymbolProc:
-+  Description: 'Use symbols as procs instead of blocks when possible.'
-+  Enabled: false
-+
-+Style/Tab:
-+  Description: 'No hard tabs.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#spaces-indentation'
-+  Enabled: false
-+
-+Style/TrailingBlankLines:
-+  Description: 'Checks trailing blank lines and final newline.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#newline-eof'
-+  Enabled: false
-+
-+Style/TrailingCommaInArguments:
-+  Description: 'Checks for trailing comma in parameter lists.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-params-comma'
-+  Enabled: false
-+
-+Style/TrailingCommaInLiteral:
-+  Description: 'Checks for trailing comma in literals.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-array-commas'
-+  Enabled: false
-+
-+Style/TrailingWhitespace:
-+  Description: 'Avoid trailing whitespace.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-whitespace'
-+  Enabled: false
-+
-+Style/TrivialAccessors:
-+  Description: 'Prefer attr_* methods to trivial readers/writers.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#attr_family'
-+  Enabled: false
-+
-+Style/UnlessElse:
-+  Description: >-
-+                 Do not use unless with else. Rewrite these with the positive
-+                 case first.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-else-with-unless'
-+  Enabled: false
-+
-+Style/UnneededCapitalW:
-+  Description: 'Checks for %W when interpolation is not needed.'
-+  Enabled: false
-+
-+Style/UnneededPercentQ:
-+  Description: 'Checks for %q/%Q when single quotes or double quotes would do.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#percent-q'
-+  Enabled: false
-+
-+Style/TrailingUnderscoreVariable:
-+  Description: >-
-+                 Checks for the usage of unneeded trailing underscores at the
-+                 end of parallel variable assignment.
-+  Enabled: false
-+
-+Style/VariableInterpolation:
-+  Description: >-
-+                 Don't interpolate global, instance and class variables
-+                 directly in strings.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#curlies-interpolate'
-+  Enabled: false
-+
-+Style/VariableName:
-+  Description: 'Use the configured style when naming variables.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#snake-case-symbols-methods-vars'
-+  Enabled: false
-+
-+Style/WhenThen:
-+  Description: 'Use when x then ... for one-line cases.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#one-line-cases'
-+  Enabled: false
-+
-+Style/WhileUntilDo:
-+  Description: 'Checks for redundant do after while or until.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-multiline-while-do'
-+  Enabled: false
-+
-+Style/WhileUntilModifier:
-+  Description: >-
-+                 Favor modifier while/until usage when you have a
-+                 single-line body.
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#while-as-a-modifier'
-+  Enabled: false
-+
-+Style/WordArray:
-+  Description: 'Use %w or %W for arrays of words.'
-+  StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#percent-w'
-+  Enabled: false
-diff --git a/coffeelint.json b/coffeelint.json
-index e69de29..86422e3 100644
---- a/coffeelint.json
-+++ b/coffeelint.json
-@@ -0,0 +1,129 @@
-+{
-+  "arrow_spacing": {
-+    "level": "ignore"
-+  },
-+  "braces_spacing": {
-+    "level": "ignore",
-+    "spaces": 0,
-+    "empty_object_spaces": 0
-+  },
-+  "camel_case_classes": {
-+    "level": "error"
-+  },
-+  "coffeescript_error": {
-+    "level": "error"
-+  },
-+  "colon_assignment_spacing": {
-+    "level": "ignore",
-+    "spacing": {
-+      "left": 0,
-+      "right": 0
-+    }
-+  },
-+  "cyclomatic_complexity": {
-+    "value": 10,
-+    "level": "ignore"
-+  },
-+  "duplicate_key": {
-+    "level": "error"
-+  },
-+  "empty_constructor_needs_parens": {
-+    "level": "ignore"
-+  },
-+  "ensure_comprehensions": {
-+    "level": "warn"
-+  },
-+  "eol_last": {
-+    "level": "ignore"
-+  },
-+  "indentation": {
-+    "value": 2,
-+    "level": "error"
-+  },
-+  "line_endings": {
-+    "level": "ignore",
-+    "value": "unix"
-+  },
-+  "max_line_length": {
-+    "value": 80,
-+    "level": "error",
-+    "limitComments": true
-+  },
-+  "missing_fat_arrows": {
-+    "level": "ignore",
-+    "is_strict": false
-+  },
-+  "newlines_after_classes": {
-+    "value": 3,
-+    "level": "ignore"
-+  },
-+  "no_backticks": {
-+    "level": "error"
-+  },
-+  "no_debugger": {
-+    "level": "warn",
-+    "console": false
-+  },
-+  "no_empty_functions": {
-+    "level": "ignore"
-+  },
-+  "no_empty_param_list": {
-+    "level": "ignore"
-+  },
-+  "no_implicit_braces": {
-+    "level": "ignore",
-+    "strict": true
-+  },
-+  "no_implicit_parens": {
-+    "strict": true,
-+    "level": "ignore"
-+  },
-+  "no_interpolation_in_single_quotes": {
-+    "level": "ignore"
-+  },
-+  "no_plusplus": {
-+    "level": "ignore"
-+  },
-+  "no_stand_alone_at": {
-+    "level": "ignore"
-+  },
-+  "no_tabs": {
-+    "level": "error"
-+  },
-+  "no_this": {
-+    "level": "ignore"
-+  },
-+  "no_throwing_strings": {
-+    "level": "error"
-+  },
-+  "no_trailing_semicolons": {
-+    "level": "error"
-+  },
-+  "no_trailing_whitespace": {
-+    "level": "error",
-+    "allowed_in_comments": false,
-+    "allowed_in_empty_lines": true
-+  },
-+  "no_unnecessary_double_quotes": {
-+    "level": "ignore"
-+  },
-+  "no_unnecessary_fat_arrows": {
-+    "level": "warn"
-+  },
-+  "non_empty_constructor_needs_parens": {
-+    "level": "ignore"
-+  },
-+  "prefer_english_operator": {
-+    "level": "ignore",
-+    "doubleNotLevel": "ignore"
-+  },
-+  "space_operators": {
-+    "level": "ignore"
-+  },
-+  "spacing_after_comma": {
-+    "level": "ignore"
-+  },
-+  "transform_messes_up_line_numbers": {
-+    "level": "warn"
-+  }
-+}
diff --git a/coffeelint.json b/coffeelint.json
deleted file mode 100644
index 86422e323d1f407c05c048bb35d67e433101e758..0000000000000000000000000000000000000000
--- a/coffeelint.json
+++ /dev/null
@@ -1,129 +0,0 @@
-{
-  "arrow_spacing": {
-    "level": "ignore"
-  },
-  "braces_spacing": {
-    "level": "ignore",
-    "spaces": 0,
-    "empty_object_spaces": 0
-  },
-  "camel_case_classes": {
-    "level": "error"
-  },
-  "coffeescript_error": {
-    "level": "error"
-  },
-  "colon_assignment_spacing": {
-    "level": "ignore",
-    "spacing": {
-      "left": 0,
-      "right": 0
-    }
-  },
-  "cyclomatic_complexity": {
-    "value": 10,
-    "level": "ignore"
-  },
-  "duplicate_key": {
-    "level": "error"
-  },
-  "empty_constructor_needs_parens": {
-    "level": "ignore"
-  },
-  "ensure_comprehensions": {
-    "level": "warn"
-  },
-  "eol_last": {
-    "level": "ignore"
-  },
-  "indentation": {
-    "value": 2,
-    "level": "error"
-  },
-  "line_endings": {
-    "level": "ignore",
-    "value": "unix"
-  },
-  "max_line_length": {
-    "value": 80,
-    "level": "error",
-    "limitComments": true
-  },
-  "missing_fat_arrows": {
-    "level": "ignore",
-    "is_strict": false
-  },
-  "newlines_after_classes": {
-    "value": 3,
-    "level": "ignore"
-  },
-  "no_backticks": {
-    "level": "error"
-  },
-  "no_debugger": {
-    "level": "warn",
-    "console": false
-  },
-  "no_empty_functions": {
-    "level": "ignore"
-  },
-  "no_empty_param_list": {
-    "level": "ignore"
-  },
-  "no_implicit_braces": {
-    "level": "ignore",
-    "strict": true
-  },
-  "no_implicit_parens": {
-    "strict": true,
-    "level": "ignore"
-  },
-  "no_interpolation_in_single_quotes": {
-    "level": "ignore"
-  },
-  "no_plusplus": {
-    "level": "ignore"
-  },
-  "no_stand_alone_at": {
-    "level": "ignore"
-  },
-  "no_tabs": {
-    "level": "error"
-  },
-  "no_this": {
-    "level": "ignore"
-  },
-  "no_throwing_strings": {
-    "level": "error"
-  },
-  "no_trailing_semicolons": {
-    "level": "error"
-  },
-  "no_trailing_whitespace": {
-    "level": "error",
-    "allowed_in_comments": false,
-    "allowed_in_empty_lines": true
-  },
-  "no_unnecessary_double_quotes": {
-    "level": "ignore"
-  },
-  "no_unnecessary_fat_arrows": {
-    "level": "warn"
-  },
-  "non_empty_constructor_needs_parens": {
-    "level": "ignore"
-  },
-  "prefer_english_operator": {
-    "level": "ignore",
-    "doubleNotLevel": "ignore"
-  },
-  "space_operators": {
-    "level": "ignore"
-  },
-  "spacing_after_comma": {
-    "level": "ignore"
-  },
-  "transform_messes_up_line_numbers": {
-    "level": "warn"
-  }
-}
diff --git a/config/locales/strings/en.posts.yml b/config/locales/strings/en.posts.yml
new file mode 100644
index 0000000000000000000000000000000000000000..541765be70799157868d796cb7c8fb62901da136
--- /dev/null
+++ b/config/locales/strings/en.posts.yml
@@ -0,0 +1,10 @@
+en:
+  posts:
+    category_low_trust_level: >
+      You don't have a high enough trust level to post in the :name category.
+    type_requires_category: >
+      You can't create a :type without a category.
+    type_requires_parent: >
+      You can't create a :type without a parent post.
+    not_public_editable: >
+      This type of post can only be edited by its author and the site moderators.
\ No newline at end of file
diff --git a/config/locales/strings/en.rate_limit.yml b/config/locales/strings/en.rate_limit.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c9ada65be06a98875031d4634deccff3eaf631fd
--- /dev/null
+++ b/config/locales/strings/en.rate_limit.yml
@@ -0,0 +1,10 @@
+rate_limit:
+  new_user_posts: >
+    You may only post :count :level posts per day. Once you have some well-received posts, your limit will increase.
+  posts: >
+    You may only post :count :level posts per day.
+  new_user_suggested_edits: >
+    You may only suggest :count edits per day. Once you have some well-received contributions, your limit will increase.
+  suggested_edits: >
+    You may only suggest :count edits per day. Once you have enough well-received suggestions, you will earn the ability
+    to edit without review.
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index 32f7fb19007f7020751834aca9b7ee556a0d66da..968af0e61643732befd039716aca7365fe327d7c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -62,54 +62,43 @@ Rails.application.routes.draw do
     patch ':id/edit',                      to: 'pinned_links#update', as: :update_pinned_link
   end
 
-  get    'questions',                      to: 'questions#index', as: :questions
   get    'questions/lottery',              to: 'questions#lottery', as: :questions_lottery
-  get    'meta',                           to: 'questions#meta', as: :meta
   get    'questions/feed',                 to: 'questions#feed', as: :question_feed
-  get    'questions/ask',                  to: 'questions#new', as: :new_question
-  get    'meta/ask',                       to: 'questions#new_meta', as: :new_meta_question
-  post   'questions/ask',                  to: 'questions#create', as: :create_question
-  get    'questions/tagged/:tag_set/:tag', to: 'questions#tagged', as: :questions_tagged
-  get    'questions/:id',                  to: 'questions#show', as: :question
-  get    'questions/:id/edit',             to: 'questions#edit', as: :edit_question
-  patch  'questions/:id/edit',             to: 'questions#update', as: :update_question
-  delete 'questions/:id/delete',           to: 'questions#destroy', as: :delete_question
-  post   'questions/:id/undelete',         to: 'questions#undelete', as: :undelete_question
-  post   'questions/:id/close',            to: 'questions#close', as: :close_question
-  post   'questions/:id/reopen',           to: 'questions#reopen', as: :reopen_question
-
-  scope 'articles' do
-    get    ':id',                          to: 'articles#show', as: :article
-    get    ':id/edit',                     to: 'articles#edit', as: :edit_article
-    patch  ':id/edit',                     to: 'articles#update', as: :update_article
-    delete ':id/delete',                   to: 'articles#destroy', as: :destroy_article
-    post   ':id/undelete',                 to: 'articles#undelete', as: :undelete_article
-  end
-
-  get    'posts/:id/history',              to: 'post_history#post', as: :post_history
-  get    'posts/search',                   to: 'search#search', as: :search
-  post   'posts/upload',                   to: 'posts#upload', as: :upload
-  post   'posts/save-draft',               to: 'posts#save_draft', as: :save_draft
-  post   'posts/delete-draft',             to: 'posts#delete_draft', as: :delete_draft
-
-  get    'posts/:id/edit',                 to: 'posts#edit', as: :edit_post
-  patch  'posts/:id/edit',                 to: 'posts#update', as: :update_post
-
-  get    'posts/new-help',                 to: 'posts#new_help', as: :new_help_post
-  post   'posts/new-help',                 to: 'posts#create_help', as: :create_help_post
-  get    'posts/:id/edit-help',            to: 'posts#edit_help', as: :edit_help_post
-  patch  'posts/:id/edit-help',            to: 'posts#update_help', as: :update_help_post
 
-  post   'posts/:id/category',             to: 'posts#change_category', as: :change_category
-  post   'posts/:id/toggle_comments',      to: 'posts#toggle_comments', as: :post_comments_allowance_toggle
-  post   'posts/:id/lock',                 to: 'posts#lock', as: :post_lock
-  post   'posts/:id/unlock',               to: 'posts#unlock', as: :post_unlock
-  post   'posts/:id/feature',              to: 'posts#feature', as: :post_feature
-
-
-  get  'posts/suggested-edit/:id',         to: 'suggested_edit#show', as: :suggested_edit
-  post 'posts/suggested-edit/:id/approve', to: 'suggested_edit#approve', as: :suggested_edit_approve
-  post 'posts/suggested-edit/:id/reject',  to: 'suggested_edit#reject', as: :suggested_edit_reject
+  scope 'posts' do
+    get    'new/:post_type',               to: 'posts#new', as: :new_post
+    get    'new/:post_type/respond/:parent', to: 'posts#new', as: :new_response
+    get    'new/:post_type/:category',     to: 'posts#new', as: :new_category_post
+    post   'new/:post_type',               to: 'posts#create', as: :create_post
+    post   'new/:post_type/respond/:parent', to: 'posts#create', as: :create_response
+    post   'new/:post_type/:category',     to: 'posts#create', as: :create_category_post
+
+    get    ':id',                          to: 'posts#show', as: :post
+
+    get    ':id/history',                  to: 'post_history#post', as: :post_history
+    get    'search',                       to: 'search#search', as: :search
+    post   'upload',                       to: 'posts#upload', as: :upload
+    post   'save-draft',                   to: 'posts#save_draft', as: :save_draft
+    post   'delete-draft',                 to: 'posts#delete_draft', as: :delete_draft
+
+    get    ':id/edit',                     to: 'posts#edit', as: :edit_post
+    patch  ':id/edit',                     to: 'posts#update', as: :update_post
+
+    post   ':id/close',                    to: 'posts#close', as: :close_post
+    post   ':id/reopen',                   to: 'posts#reopen', as: :reopen_post
+    post   ':id/delete',                   to: 'posts#delete', as: :delete_post
+    post   ':id/restore',                  to: 'posts#restore', as: :restore_post
+
+    post   ':id/category',                 to: 'posts#change_category', as: :change_category
+    post   ':id/toggle_comments',          to: 'posts#toggle_comments', as: :post_comments_allowance_toggle
+    post   ':id/lock',                     to: 'posts#lock', as: :post_lock
+    post   ':id/unlock',                   to: 'posts#unlock', as: :post_unlock
+    post   ':id/feature',                  to: 'posts#feature', as: :post_feature
+
+    get    'suggested-edit/:id',           to: 'suggested_edit#show', as: :suggested_edit
+    post   'suggested-edit/:id/approve',   to: 'suggested_edit#approve', as: :suggested_edit_approve
+    post   'suggested-edit/:id/reject',    to: 'suggested_edit#reject', as: :suggested_edit_reject
+  end
 
   get    'policy/:slug',                   to: 'posts#document', as: :policy
   get    'help/:slug',                     to: 'posts#document', as: :help
@@ -156,12 +145,6 @@ Rails.application.routes.draw do
   post   'votes/new',                      to: 'votes#create', as: :create_vote
   delete 'votes/:id',                      to: 'votes#destroy', as: :destroy_vote
 
-  get    'questions/:id/answer',           to: 'answers#new', as: :new_answer
-  post   'questions/:id/answer',           to: 'answers#create', as: :create_answer
-  get    'answers/:id/edit',               to: 'answers#edit', as: :edit_answer
-  patch  'answers/:id/edit',               to: 'answers#update', as: :update_answer
-  delete 'answers/:id/delete',             to: 'answers#destroy', as: :delete_answer
-  post   'answers/:id/delete',             to: 'answers#undelete', as: :undelete_answer
   post   'answers/:id/convert',            to: 'answers#convert_to_comment', as: :convert_to_comment
 
   post   'flags/new',                      to: 'flags#new', as: :new_flag
@@ -173,10 +156,6 @@ Rails.application.routes.draw do
   delete 'comments/:id/delete',            to: 'comments#destroy', as: :delete_comment
   patch  'comments/:id/delete',            to: 'comments#undelete', as: :undelete_comment
 
-  get    'q/:id',                          to: 'posts#share_q', as: :share_question
-  get    'a/:qid/:id',                     to: 'posts#share_a', as: :share_answer
-  get    'ar/:id',                         to: 'articles#share', as: :share_article
-
   get    'subscriptions/new/:type',        to: 'subscriptions#new', as: :new_subscription
   post   'subscriptions/new',              to: 'subscriptions#create', as: :create_subscription
   get    'subscriptions',                  to: 'subscriptions#index', as: :subscriptions
@@ -192,24 +171,23 @@ Rails.application.routes.draw do
   get    'help',                           to: 'posts#help_center', as: :help_center
 
   scope 'categories' do
-    root                                           to: 'categories#index', as: :categories
-    get    'new',                                  to: 'categories#new', as: :new_category
-    post   'new',                                  to: 'categories#create', as: :create_category
-    get    ':category_id/posts/new/:post_type_id', to: 'posts#new', as: :new_post
-    post   ':category_id/posts/new/:post_type_id', to: 'posts#create', as: :create_post
-    get    ':id',                                  to: 'categories#show', as: :category
-    get    ':id/edit',                             to: 'categories#edit', as: :edit_category
-    post   ':id/edit',                             to: 'categories#update', as: :update_category
-    delete ':id',                                  to: 'categories#destroy', as: :destroy_category
-    get    ':id/feed',                             to: 'categories#rss_feed', as: :category_feed
-    get    ':id/tags',                             to: 'tags#category', as: :category_tags
-    get    ':id/tags/:tag_id',                     to: 'tags#show', as: :tag
-    get    ':id/tags/:tag_id/children',            to: 'tags#children', as: :tag_children
-    get    ':id/tags/:tag_id/edit',                to: 'tags#edit', as: :edit_tag
-    patch  ':id/tags/:tag_id/edit',                to: 'tags#update', as: :update_tag
-    post   ':id/tags/:tag_id/rename',              to: 'tags#rename', as: :rename_tag
-    get    ':id/tags/:tag_id/merge',               to: 'tags#select_merge', as: :select_tag_merge
-    post   ':id/tags/:tag_id/merge',               to: 'tags#merge', as: :merge_tag
+    root                                   to: 'categories#index', as: :categories
+    get    'new',                          to: 'categories#new', as: :new_category
+    post   'new',                          to: 'categories#create', as: :create_category
+    get    ':id',                          to: 'categories#show', as: :category
+    get    ':id/edit',                     to: 'categories#edit', as: :edit_category
+    post   ':id/edit',                     to: 'categories#update', as: :update_category
+    delete ':id',                          to: 'categories#destroy', as: :destroy_category
+    get    ':id/types',                    to: 'categories#post_types', as: :category_post_types
+    get    ':id/feed',                     to: 'categories#rss_feed', as: :category_feed
+    get    ':id/tags',                     to: 'tags#category', as: :category_tags
+    get    ':id/tags/:tag_id',             to: 'tags#show', as: :tag
+    get    ':id/tags/:tag_id/children',    to: 'tags#children', as: :tag_children
+    get    ':id/tags/:tag_id/edit',        to: 'tags#edit', as: :edit_tag
+    patch  ':id/tags/:tag_id/edit',        to: 'tags#update', as: :update_tag
+    post   ':id/tags/:tag_id/rename',      to: 'tags#rename', as: :rename_tag
+    get    ':id/tags/:tag_id/merge',       to: 'tags#select_merge', as: :select_tag_merge
+    post   ':id/tags/:tag_id/merge',       to: 'tags#merge', as: :merge_tag
   end
 
   get   'warning',                         to: 'mod_warning#current', as: :current_mod_warning
diff --git a/db/migrate/20201204170746_add_columns_to_post_type.rb b/db/migrate/20201204170746_add_columns_to_post_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..560bb722ba8fcabb37f13d02e17aa0a6791bed62
--- /dev/null
+++ b/db/migrate/20201204170746_add_columns_to_post_type.rb
@@ -0,0 +1,32 @@
+class AddColumnsToPostType < ActiveRecord::Migration[5.2]
+  def change
+    change_table :post_types do |t|
+      t.text :description
+      t.boolean :has_answers, null: false, default: false
+      t.boolean :has_votes, null: false, default: false
+      t.boolean :has_tags, null: false, default: false
+      t.boolean :has_parent, null: false, default: false
+      t.boolean :has_category, null: false, default: false
+      t.boolean :has_license, null: false, default: false
+      t.boolean :is_public_editable, null: false, default: false
+      t.boolean :is_closeable, null: false, default: false
+      t.boolean :is_top_level, null: false, default: false
+    end
+
+    data = {
+      'Question' => { has_answers: true, has_votes: true, has_tags: true, has_parent: false, has_category: true,
+                      has_license: true, is_public_editable: true, is_closeable: true, is_top_level: true },
+      'Answer' => { has_answers: false, has_votes: true, has_tags: false, has_parent: true, has_category: true,
+                    has_license: true, is_public_editable: true, is_closeable: false, is_top_level: false },
+      'HelpDoc' => { has_answers: false, has_votes: false, has_tags: false, has_parent: false, has_category: false,
+                     has_license: false, is_public_editable: false, is_closeable: false, is_top_level: false },
+      'PolicyDoc' => { has_answers: false, has_votes: false, has_tags: false, has_parent: false, has_category: false,
+                       has_license: false, is_public_editable: false, is_closeable: false, is_top_level: false },
+      'Article' => { has_answers: false, has_votes: true, has_tags: true, has_parent: false, has_category: true,
+                     has_license: true, is_public_editable: false, is_closeable: false, is_top_level: true }
+    }
+    PostType.unscoped.all.each do |pt|
+      pt.update(data[pt.name])
+    end
+  end
+end
diff --git a/db/migrate/20201212235514_move_trust_level_to_community_user.rb b/db/migrate/20201212235514_move_trust_level_to_community_user.rb
new file mode 100644
index 0000000000000000000000000000000000000000..25d86d7b35190a3c1c0d13b51da6cddf70109b3a
--- /dev/null
+++ b/db/migrate/20201212235514_move_trust_level_to_community_user.rb
@@ -0,0 +1,6 @@
+class MoveTrustLevelToCommunityUser < ActiveRecord::Migration[5.2]
+  def change
+    add_column :community_users, :trust_level, :integer
+    remove_column :users, :trust_level
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 78e6e236ef452b49305057f932a819d23445c563..8c4c796443e9b058652f5aeab6014b1a8c892de0 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2020_11_23_132854) do
+ActiveRecord::Schema.define(version: 2020_12_12_235514) do
 
   create_table "abilities", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci", force: :cascade do |t|
     t.bigint "community_id"
@@ -170,6 +170,7 @@ ActiveRecord::Schema.define(version: 2020_11_23_132854) do
     t.boolean "is_suspended"
     t.datetime "suspension_end"
     t.string "suspension_public_comment"
+    t.integer "trust_level"
     t.index ["community_id"], name: "index_community_users_on_community_id"
     t.index ["user_id"], name: "index_community_users_on_user_id"
   end
@@ -298,6 +299,16 @@ ActiveRecord::Schema.define(version: 2020_11_23_132854) do
 
   create_table "post_types", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", force: :cascade do |t|
     t.string "name"
+    t.text "description"
+    t.boolean "has_answers", default: false, null: false
+    t.boolean "has_votes", default: false, null: false
+    t.boolean "has_tags", default: false, null: false
+    t.boolean "has_parent", default: false, null: false
+    t.boolean "has_category", default: false, null: false
+    t.boolean "has_license", default: false, null: false
+    t.boolean "is_public_editable", default: false, null: false
+    t.boolean "is_closeable", default: false, null: false
+    t.boolean "is_top_level", default: false, null: false
     t.index ["name"], name: "index_post_types_on_name"
   end
 
@@ -337,7 +348,7 @@ ActiveRecord::Schema.define(version: 2020_11_23_132854) do
     t.boolean "comments_disabled"
     t.datetime "last_edited_at"
     t.bigint "last_edited_by_id"
-    t.boolean "locked"
+    t.boolean "locked", default: false, null: false
     t.bigint "locked_by_id"
     t.datetime "locked_at"
     t.datetime "locked_until"
@@ -521,7 +532,6 @@ ActiveRecord::Schema.define(version: 2020_11_23_132854) do
     t.text "profile_markdown"
     t.integer "se_acct_id"
     t.boolean "transferred_content", default: false
-    t.integer "trust_level"
     t.string "login_token"
     t.datetime "login_token_expires_at"
     t.string "two_factor_token"
diff --git a/test/controllers/answers_controller_test.rb b/test/controllers/answers_controller_test.rb
index fd5c2aaa540ce1cbd4176a2c6054c2cc38e9e68f..b5a1ade00e1d7e619cdc6820e74f6430a31f031e 100644
--- a/test/controllers/answers_controller_test.rb
+++ b/test/controllers/answers_controller_test.rb
@@ -3,173 +3,6 @@ require 'test_helper'
 class AnswersControllerTest < ActionController::TestCase
   include Devise::Test::ControllerHelpers
 
-  test 'should get new answer page' do
-    sign_in users(:standard_user)
-    get :new, params: { id: posts(:question_one).id }
-    assert_response(200)
-    assert_not_nil assigns(:answer)
-    assert_not_nil assigns(:question)
-  end
-
-  test 'should create new answer' do
-    sign_in users(:standard_user)
-    post :create, params: { answer: { body_markdown: 'ABCDEF ABCDEF ABCDEF ABCDEF GH', license_id: licenses(:cc_by_sa).id },
-                            id: posts(:question_one).id }
-    assert_not_nil assigns(:answer)
-    assert_not_nil assigns(:question)
-    assert_response(302)
-  end
-
-  test 'should get edit answer page' do
-    sign_in users(:editor)
-    get :edit, params: { id: posts(:answer_one).id }
-    assert_response(200)
-    assert_not_nil assigns(:answer)
-  end
-
-  test 'should update existing answer' do
-    sign_in users(:editor)
-    patch :update, params: { answer: { body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ' }, id: posts(:answer_one).id }
-    assert_not_nil assigns(:answer)
-    assert_response(302)
-  end
-
-  test 'should mark answer deleted' do
-    sign_in users(:deleter)
-    delete :destroy, params: { id: posts(:answer_one).id }
-    assert_not_nil assigns(:answer)
-    assert_equal true, assigns(:answer).deleted
-    assert_not_nil assigns(:answer).deleted_at
-    assert_response(302)
-  end
-
-  test 'should mark answer undeleted' do
-    sign_in users(:deleter)
-    delete :undelete, params: { id: posts(:deleted_answer).id }
-    assert_not_nil assigns(:answer)
-    assert_equal false, assigns(:answer).deleted
-    assert_nil assigns(:answer).deleted_at
-    assert_response(302)
-  end
-
-  test 'should require authentication to get new page' do
-    sign_out :user
-    get :new, params: { id: posts(:question_one).id }
-    assert_response(302)
-  end
-
-  test 'should require authentication to create answer' do
-    sign_out :user
-    post :create, params: { id: posts(:question_one).id }
-    assert_response(302)
-  end
-
-  test 'should require authentication to get edit page' do
-    sign_out :user
-    get :edit, params: { id: posts(:answer_one).id }
-    assert_response(302)
-  end
-
-  test 'should require authentication to update answer' do
-    sign_out :user
-    patch :update, params: { id: posts(:answer_one).id }
-    assert_response(302)
-  end
-
-  test 'should require authentication to delete' do
-    sign_out :user
-    delete :destroy, params: { id: posts(:answer_one).id }
-    assert_response(302)
-  end
-
-  test 'should require authentication to undelete' do
-    sign_out :user
-    delete :undelete, params: { id: posts(:answer_one).id }
-    assert_response(302)
-  end
-
-  test 'should require above standard privileges to delete' do
-    sign_in users(:standard_user)
-    delete :destroy, params: { id: posts(:answer_two).id }
-    assert_response(302)
-    assert_not_nil flash[:danger]
-    assert_equal false, assigns(:answer).deleted
-  end
-
-  test 'should require above standard privileges to undelete' do
-    sign_in users(:standard_user)
-    delete :undelete, params: { id: posts(:deleted_answer).id }
-    assert_response(302)
-    assert_not_nil flash[:danger]
-    assert_equal true, assigns(:answer).deleted
-  end
-
-  test 'should require above edit privileges to delete' do
-    sign_in users(:editor)
-    delete :destroy, params: { id: posts(:answer_one).id }
-    assert_response(302)
-    assert_not_nil flash[:danger]
-    assert_equal false, assigns(:answer).deleted
-  end
-
-  test 'should require above edit privileges to undelete' do
-    sign_in users(:editor)
-    delete :undelete, params: { id: posts(:deleted_answer).id }
-    assert_response(302)
-    assert_not_nil flash[:danger]
-    assert_equal true, assigns(:answer).deleted
-  end
-
-  test 'should allow author to get edit page' do
-    sign_in users(:standard_user)
-    get :edit, params: { id: posts(:answer_one).id }
-    assert_not_nil assigns(:answer)
-    assert_response(200)
-  end
-
-  test 'should allow author to update answer' do
-    sign_in users(:standard_user)
-    patch :update, params: { answer: { body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ' }, id: posts(:answer_one).id }
-    assert_not_nil assigns(:answer)
-    assert_response(302)
-  end
-
-  test 'should allow author to delete answer' do
-    sign_in users(:standard_user)
-    delete :destroy, params: { id: posts(:answer_one).id }
-    assert_not_nil assigns(:answer)
-    assert_nil flash[:danger]
-    assert_response(302)
-  end
-
-  test 'should allow author to undelete answer' do
-    sign_in users(:closer)
-    delete :undelete, params: { id: posts(:deleted_answer).id }
-    assert_not_nil assigns(:answer)
-    assert_nil flash[:danger]
-    assert_response(302)
-  end
-
-  test 'should block short answers' do
-    sign_in users(:standard_user)
-    post :create, params: { answer: { body_markdown: 'ABCDEF' }, id: posts(:question_one).id }
-    assert_response(422)
-  end
-
-  test 'should block whitespace answers' do
-    sign_in users(:standard_user)
-    post :create, params: { answer: { body_markdown: ' ' * 31, license_id: licenses(:cc_by_sa).id },
-                            id: posts(:question_one).id }
-    assert_response(422)
-  end
-
-  test 'should block long answers' do
-    sign_in users(:standard_user)
-    post :create, params: { answer: { body_markdown: 'A' * (3e4 + 1), license_id: licenses(:cc_by_sa).id },
-                            id: posts(:question_one).id }
-    assert_response(422)
-  end
-
   test 'should convert to comment' do
     sign_in users(:moderator)
     pre_count = posts(:question_one).comments.count
diff --git a/test/controllers/articles_controller_test.rb b/test/controllers/articles_controller_test.rb
index 07a6bacfc1155cef057d030412fddcc001c3d200..87c9519803a319d90a480b54985ebba54c2db525 100644
--- a/test/controllers/articles_controller_test.rb
+++ b/test/controllers/articles_controller_test.rb
@@ -2,57 +2,4 @@ require 'test_helper'
 
 class ArticlesControllerTest < ActionController::TestCase
   include Devise::Test::ControllerHelpers
-  include ApplicationTestHelper
-
-  test 'should get show article page' do
-    get :show, params: { id: posts(:article_one).id }
-    assert_not_nil assigns(:article)
-    assert_response(200)
-  end
-
-  test 'should get show article page with deleted article' do
-    sign_in users(:deleter)
-    get :show, params: { id: posts(:deleted_article).id }
-    assert_not_nil assigns(:article)
-    assert_response(200)
-  end
-
-  test 'should prevent unprivileged user seeing deleted post' do
-    get :show, params: { id: posts(:deleted_article).id }
-    assert_response 404
-  end
-
-  test 'should get edit article page' do
-    sign_in users(:editor)
-    get :edit, params: { id: posts(:article_one).id }
-    assert_not_nil assigns(:article)
-    assert_response(200)
-  end
-
-  test 'should update existing article' do
-    sign_in users(:editor)
-    patch :update, params: { id: posts(:article_one).id, article: { title: 'ABCDEF GHIJKL MNOPQR',
-                                                                    body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ',
-                                                                    tags_cache: ['discussion', 'support'] } }
-    assert_not_nil assigns(:article)
-    assert_array_equal ['discussion', 'support'], assigns(:article).tags_cache
-    assert_array_equal ['discussion', 'support'], assigns(:article).tags.map(&:name)
-    assert_response(302)
-  end
-
-  test 'should mark article deleted' do
-    sign_in users(:deleter)
-    delete :destroy, params: { id: posts(:article_one).id }
-    assert_not_nil assigns(:article)
-    assert_equal true, assigns(:article).deleted
-    assert_response(302)
-  end
-
-  test 'should mark article undeleted' do
-    sign_in users(:deleter)
-    delete :undelete, params: { id: posts(:deleted_article).id }
-    assert_not_nil assigns(:article)
-    assert_equal false, assigns(:article).deleted
-    assert_response(302)
-  end
 end
diff --git a/test/controllers/mod_warning_controller_test.rb b/test/controllers/mod_warning_controller_test.rb
index d839d8c6a2d46184539ea93e5e3aac23db6bc42b..e2a0c2a1022707f3bd355039287f49631def0fb7 100644
--- a/test/controllers/mod_warning_controller_test.rb
+++ b/test/controllers/mod_warning_controller_test.rb
@@ -38,7 +38,7 @@ class ModWarningControllerTest < ActionController::TestCase
     @warning.update(active: true)
     post :approve, params: { approve_checkbox: true }
     @warning.reload
-    assert !@warning.active
+    assert_not @warning.active
   end
 
   test 'suspended user should not be able to accept pending suspension' do
@@ -56,6 +56,6 @@ class ModWarningControllerTest < ActionController::TestCase
     @warning.update(active: true)
     post :approve, params: { approve_checkbox: true }
     @warning.reload
-    assert !@warning.active
+    assert_not @warning.active
   end
 end
diff --git a/test/controllers/posts_controller_test.rb b/test/controllers/posts_controller_test.rb
index 46e135e585c73093260847bdb06c53896514d979..f59de979b46d35a966f937339cd2a65274036a39 100644
--- a/test/controllers/posts_controller_test.rb
+++ b/test/controllers/posts_controller_test.rb
@@ -3,214 +3,901 @@ require 'test_helper'
 class PostsControllerTest < ActionController::TestCase
   include Devise::Test::ControllerHelpers
 
-  test 'should require authentication to get new_help' do
-    get :new_help
-    assert_response 302
+  # Help
+
+  test 'can get help center' do
+    get :help_center
+    assert_response 200
+    assert_not_nil assigns(:posts)
   end
 
-  test 'should require authentication to get edit_help' do
-    get :edit_help, params: { id: posts(:policy_doc).id }
-    assert_response 302
+  test 'can get help article' do
+    get :document, params: { slug: posts(:help_article).doc_slug }
+    assert_response 200
+    assert_not_nil assigns(:post)
   end
 
-  test 'should deny regular users access to new_help' do
-    sign_in users(:standard_user)
-    get :new_help
+  test 'moderator can get mod help article' do
+    sign_in users(:moderator)
+    get :document, params: { slug: posts(:mod_help_article).doc_slug }
+    assert_response 200
+    assert_not_nil assigns(:post)
+  end
+
+  test 'moderator help requires authentication' do
+    get :document, params: { slug: posts(:mod_help_article).doc_slug }
     assert_response 404
+    assert_not_nil assigns(:post)
   end
 
-  test 'should deny regular users access to edit_help' do
+  test 'regular user cannot get mod help' do
     sign_in users(:standard_user)
-    get :edit_help, params: { id: posts(:policy_doc).id }
+    get :document, params: { slug: posts(:mod_help_article).doc_slug }
     assert_response 404
+    assert_not_nil assigns(:post)
   end
 
-  test 'should allow moderators access to new_help' do
+  test 'cannot get disabled help article' do
     sign_in users(:moderator)
-    get :new_help
+    get :document, params: { slug: posts(:disabled_help_article).doc_slug }
+    assert_response 404
+    assert_not_nil assigns(:post)
+  end
+
+  # Change category
+
+  test 'should change category' do
+    sign_in users(:deleter)
+    post :change_category, params: { id: posts(:article_one).id, target_id: categories(:articles_only).id }
     assert_response 200
+    assert_not_nil assigns(:post)
+    assert_not_nil assigns(:target)
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal categories(:articles_only).id, assigns(:post).category_id
+  end
+
+  test 'should deny change category to unprivileged' do
+    sign_in users(:standard_user)
+    post :change_category, params: { id: posts(:article_one).id, target_id: categories(:articles_only).id }
+    assert_response 403
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal ["You don't have permission to make that change."], JSON.parse(response.body)['errors']
   end
 
-  test 'should allow moderators access to edit_help' do
+  test 'should refuse to change category of wrong post type' do
+    sign_in users(:deleter)
+    post :change_category, params: { id: posts(:question_one).id, target_id: categories(:articles_only).id }
+    assert_response 409
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal ["This post type is not allowed in the #{categories(:articles_only).name} category."],
+                 JSON.parse(response.body)['errors']
+  end
+
+  # New
+
+  test 'should get new' do
     sign_in users(:moderator)
-    get :edit_help, params: { id: posts(:help_doc).id }
+    get :new, params: { post_type: post_types(:help_doc).id }
+    assert_nil flash[:danger]
     assert_response 200
-    assert_not_nil assigns(:post)
+
+    get :new, params: { post_type: post_types(:answer).id, parent: posts(:question_one).id }
+    assert_nil flash[:danger]
+    assert_response 200
+
+    get :new, params: { post_type: post_types(:question).id, category: categories(:main).id }
+    assert_nil flash[:danger]
+    assert_response 200
+  end
+
+  test 'new requires authentication' do
+    get :new, params: { post_type: post_types(:help_doc).id }
+    assert_redirected_to new_user_session_path
+    get :new, params: { post_type: post_types(:answer).id, parent: posts(:question_one).id }
+    assert_redirected_to new_user_session_path
+    get :new, params: { post_type: post_types(:question).id, category: categories(:main).id }
+    assert_redirected_to new_user_session_path
   end
 
-  test 'should disallow regular users to create help doc' do
+  test 'new rejects category post type without category' do
     sign_in users(:standard_user)
-    post :create_help, params: { post: { post_type_id: HelpDoc.post_type_id, body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ',
-                                         title: 'ABCDEF GHIJKL MNOPQR', doc_slug: 'help-doc' } }
-    assert_response 404
+    get :new, params: { post_type: post_types(:question).id }
+    assert_response 302
+    assert_redirected_to root_path
+    assert_not_nil flash[:danger]
+  end
+
+  test 'new rejects parented post type without parent' do
+    sign_in users(:standard_user)
+    get :new, params: { post_type: post_types(:answer).id }
+    assert_response 302
+    assert_redirected_to root_path
+    assert_not_nil flash[:danger]
   end
 
-  test 'should allow moderators to create help doc' do
+  # Create
+
+  test 'can create help post' do
     sign_in users(:moderator)
-    post :create_help, params: { post: { post_type_id: HelpDoc.post_type_id, body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ',
-                                         title: 'ABCDEF GHIJKL MNOPQR', doc_slug: 'help-doc' } }
+    post :create, params: { post_type: post_types(:help_doc).id,
+                            post: { post_type_id: post_types(:help_doc).id, title: sample.title, doc_slug: 'topic',
+                                    body_markdown: sample.body_markdown, help_category: 'A', help_ordering: '99' } }
+    assert_response 302
+    assert_not_nil assigns(:post).id
+    assert_redirected_to help_path(assigns(:post).doc_slug)
+  end
+
+  test 'can create category post' do
+    sign_in users(:standard_user)
+    post :create, params: { post_type: post_types(:question).id, category: categories(:main).id,
+                            post: { post_type_id: post_types(:question).id, title: sample.title,
+                                    body_markdown: sample.body_markdown, category_id: categories(:main).id,
+                                    tags_cache: sample.tags_cache } }
+    assert_response 302
+    assert_not_nil assigns(:post).id
+    assert_redirected_to post_path(assigns(:post))
+  end
+
+  test 'can create answer' do
+    sign_in users(:standard_user)
+    post :create, params: { post_type: post_types(:answer).id, parent: posts(:question_one).id,
+                            post: { post_type_id: post_types(:answer).id, title: sample.title,
+                                    body_markdown: sample.body_markdown, parent_id: posts(:question_one).id } }
     assert_response 302
-    assert_not_nil assigns(:post)
     assert_not_nil assigns(:post).id
+    assert_redirected_to post_path(posts(:question_one).id, anchor: "answer-#{assigns(:post).id}")
+  end
+
+  test 'create requires authentication' do
+    post :create, params: { post_type: post_types(:question).id, category: categories(:main).id,
+                            post: { post_type_id: post_types(:question).id, title: sample.title,
+                                    body_markdown: sample.body_markdown, category_id: categories(:main).id,
+                                    tags_cache: sample.tags_cache } }
+    assert_response 302
+    assert_redirected_to new_user_session_path
+  end
+
+  test 'standard users cannot create help posts' do
+    sign_in users(:standard_user)
+    post :create, params: { post_type: post_types(:help_doc).id,
+                            post: { post_type_id: post_types(:help_doc).id, title: sample.title, doc_slug: 'topic',
+                                    body_markdown: sample.body_markdown, help_category: 'A', help_ordering: '99' } }
+    assert_response 404
   end
 
-  test 'should disallow moderators to create policy doc' do
+  test 'moderators cannot create policy posts' do
     sign_in users(:moderator)
-    post :create_help, params: { post: { post_type_id: PolicyDoc.post_type_id, body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ',
-                                         title: 'ABCDEF GHIJKL MNOPQR', doc_slug: 'policy-doc' } }
+    post :create, params: { post_type: post_types(:policy_doc).id,
+                            post: { post_type_id: post_types(:policy_doc).id, title: sample.title, doc_slug: 'topic',
+                                    body_markdown: sample.body_markdown, help_category: 'A', help_ordering: '99' } }
+    assert_response 404
+  end
+
+  test 'category post type rejects without category' do
+    sign_in users(:standard_user)
+    post :create, params: { post_type: post_types(:question).id,
+                            post: { post_type_id: post_types(:question).id, title: sample.title,
+                                    body_markdown: sample.body_markdown, tags_cache: sample.tags_cache } }
+    assert_response 302
+    assert_redirected_to root_path
+    assert_not_nil flash[:danger]
+    assert_nil assigns(:post).id
+  end
+
+  test 'category post type checks required trust level' do
+    sign_in users(:standard_user)
+    post :create, params: { post_type: post_types(:question).id, category: categories(:high_trust).id,
+                            post: { post_type_id: post_types(:question).id, title: sample.title,
+                                    body_markdown: sample.body_markdown, category_id: categories(:high_trust).id,
+                                    tags_cache: sample.tags_cache } }
     assert_response 403
     assert_nil assigns(:post).id
-    assert_equal true, assigns(:post).errors.any?
+    assert_not_empty assigns(:post).errors.full_messages
   end
 
-  test 'should allow admins to create policy doc' do
-    sign_in users(:admin)
-    post :create_help, params: { post: { post_type_id: PolicyDoc.post_type_id, body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ',
-                                         title: 'ABCDEF GHIJKL MNOPQR', doc_slug: 'policy-doc' } }
+  test 'parented post type rejects without parent' do
+    sign_in users(:standard_user)
+    post :create, params: { post_type: post_types(:answer).id,
+                            post: { post_type_id: post_types(:answer).id, title: sample.title,
+                                    body_markdown: sample.body_markdown } }
     assert_response 302
+    assert_redirected_to root_path
+    assert_not_nil flash[:danger]
+    assert_nil assigns(:post).id
+  end
+
+  test 'create ensures community user is created' do
+    user = users(:no_community_user)
+    before = CommunityUser.where(user: user, community: communities(:sample)).count
+
+    sign_in user
+    post :create, params: { post_type: post_types(:question).id, category: categories(:main).id,
+                            post: { post_type_id: post_types(:question).id, title: sample.title,
+                                    body_markdown: sample.body_markdown, category_id: categories(:main).id,
+                                    tags_cache: sample.tags_cache } }
+
+    after = CommunityUser.where(user: user, community: communities(:sample)).count
+    assert_equal before + 1, after, 'No CommunityUser record was created'
+  end
+
+  # Show
+
+  test 'anonymous user can get show' do
+    get :show, params: { id: posts(:question_one).id }
+    assert_response 200
     assert_not_nil assigns(:post)
-    assert_not_nil assigns(:post).id
+    assert_not_nil assigns(:children)
+    assert_not assigns(:children).any?(&:deleted), 'Anonymous user can see deleted answers'
   end
 
-  test 'should disallow regular users to edit help doc' do
+  test 'standard user can get show' do
     sign_in users(:standard_user)
-    patch :update_help, params: { id: posts(:help_doc).id,
-                                  post: { body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ', title: 'ABCDEF GHIJKL MNOPQR' } }
-    assert_response 404
+    get :show, params: { id: posts(:question_one).id }
+    assert_response 200
+    assert_not_nil assigns(:post)
+    assert_not_nil assigns(:children)
+    assert_not assigns(:children).any?(&:deleted), 'Anonymous user can see deleted answers'
   end
 
-  test 'should allow moderators to edit help doc' do
-    sign_in users(:moderator)
-    patch :update_help, params: { id: posts(:help_doc).id,
-                                  post: { body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ', title: 'ABCDEF GHIJKL MNOPQR' } }
+  test 'privileged user can see deleted post' do
+    sign_in users(:deleter)
+    get :show, params: { id: posts(:deleted).id }
+    assert_response 200
+    assert_not_nil assigns(:post)
+    assert_not_nil assigns(:children)
+  end
+
+  test 'privileged user can see deleted answers' do
+    sign_in users(:deleter)
+    get :show, params: { id: posts(:question_one).id }
+    assert_response 200
+    assert_not_nil assigns(:post)
+    assert_not_nil assigns(:children)
+    assert assigns(:children).any?(&:deleted), 'Privileged user cannot see deleted answers'
+  end
+
+  test 'show redirects parented to parent post' do
+    get :show, params: { id: posts(:answer_one).id }
     assert_response 302
+    assert_redirected_to post_path(posts(:answer_one).parent_id)
+  end
+
+  # Edit
+
+  test 'can get edit' do
+    sign_in users(:standard_user)
+    get :edit, params: { id: posts(:question_one).id }
+    assert_response 200
     assert_not_nil assigns(:post)
-    assert_not_nil assigns(:post).id
   end
 
-  test 'should disallow moderators to edit policy doc' do
-    sign_in users(:moderator)
-    patch :update_help, params: { id: posts(:policy_doc).id,
-                                  post: { body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ', title: 'ABCDEF GHIJKL MNOPQR' } }
-    assert_response 404
+  test 'edit requires authentication' do
+    get :edit, params: { id: posts(:question_one).id }
+    assert_response 302
+    assert_redirected_to new_user_session_path
+  end
+
+  test 'cannot edit locked post' do
+    sign_in users(:standard_user)
+    get :edit, params: { id: posts(:locked).id }
+    assert_response 401
   end
 
-  test 'should allow admins to edit policy doc' do
-    sign_in users(:admin)
-    patch :update_help, params: { id: posts(:policy_doc).id,
-                                  post: { body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ', title: 'ABCDEF GHIJKL MNOPQR' } }
+  test 'cannot edit non-public post without permissions' do
+    sign_in users(:standard_user)
+    get :edit, params: { id: posts(:blog_post).id }
     assert_response 302
+    assert_redirected_to root_path
+    assert_not_nil flash[:danger]
+  end
+
+  test 'author can edit non-public post' do
+    sign_in users(:closer)
+    get :edit, params: { id: posts(:blog_post).id }
+    assert_response 200
     assert_not_nil assigns(:post)
-    assert_not_nil assigns(:post).id
   end
 
-  test 'should successfully get help center' do
-    get :help_center
+  test 'moderator can edit non-public post' do
+    sign_in users(:moderator)
+    get :edit, params: { id: posts(:blog_post).id }
     assert_response 200
-    assert_not_nil assigns(:posts)
+    assert_not_nil assigns(:post)
   end
 
-  test 'question permalink should correctly redirect' do
-    get :share_q, params: { id: posts(:question_one).id }
+  # Update
+
+  test 'can update post' do
+    sign_in users(:standard_user)
+    before_history = PostHistory.where(post: posts(:question_one)).count
+    patch :update, params: { id: posts(:question_one).id,
+                             post: { title: sample.edit.title, body_markdown: sample.edit.body_markdown,
+                                     tags_cache: sample.edit.tags_cache } }
+    after_history = PostHistory.where(post: posts(:question_one)).count
     assert_response 302
-    assert_redirected_to question_path(posts(:question_one))
+    assert_redirected_to post_path(posts(:question_one))
+    assert_not_nil assigns(:post)
+    assert_equal sample.edit.body_markdown, assigns(:post).body_markdown
+    assert_equal before_history + 1, after_history, 'No PostHistory event created on author update'
   end
 
-  test 'answer permalink should correctly redirect' do
-    get :share_a, params: { qid: posts(:question_one).id, id: posts(:answer_one).id }
+  test 'moderators can update post' do
+    sign_in users(:moderator)
+    before_history = PostHistory.where(post: posts(:question_one)).count
+    patch :update, params: { id: posts(:question_one).id,
+                             post: { title: sample.edit.title, body_markdown: sample.edit.body_markdown,
+                                     tags_cache: sample.edit.tags_cache } }
+    after_history = PostHistory.where(post: posts(:question_one)).count
     assert_response 302
-    assert_redirected_to question_path(id: posts(:question_one).id, anchor: "answer-#{posts(:answer_one).id}")
+    assert_redirected_to post_path(posts(:question_one))
+    assert_not_nil assigns(:post)
+    assert_equal sample.edit.body_markdown, assigns(:post).body_markdown
+    assert_equal before_history + 1, after_history, 'No PostHistory event created on moderator update'
   end
 
-  test 'should require sign in to write post' do
-    get :new, params: { category_id: categories(:main).id, post_type_id: post_types(:question).id }
+  test 'update requires authentication' do
+    patch :update, params: { id: posts(:question_one).id,
+                             post: { title: sample.edit.title, body_markdown: sample.edit.body_markdown,
+                                     tags_cache: sample.edit.tags_cache } }
     assert_response 302
     assert_redirected_to new_user_session_path
   end
 
-  test 'should require sign in to create post' do
-    post :create, params: { category_id: categories(:main).id, post_type_id: post_types(:question).id,
-                            post: { body_markdown: 'ABCD EFGH IJKL MNOP QRST UVWX YZ', title: 'ABCD EFGH IJKL M',
-                                    tags_cache: ['discussion', 'support', 'bug', 'feature-request'],
-                                    license_id: licenses(:cc_by_sa).id } }
+  test 'update by unprivileged user generates suggested edit' do
+    sign_in users(:closer)
+    before_history = PostHistory.where(post: posts(:question_one)).count
+    before_edits = SuggestedEdit.where(post: posts(:question_one)).count
+    before_body = posts(:question_one).body_markdown
+    patch :update, params: { id: posts(:question_one).id,
+                             post: { title: sample.edit.title, body_markdown: sample.edit.body_markdown,
+                                     tags_cache: sample.edit.tags_cache } }
+    after_history = PostHistory.where(post: posts(:question_one)).count
+    after_edits = SuggestedEdit.where(post: posts(:question_one)).count
     assert_response 302
-    assert_redirected_to new_user_session_path
+    assert_redirected_to post_path(posts(:question_one))
+    assert_not_nil assigns(:post)
+    assert_equal before_body, assigns(:post).body_markdown, 'Suggested edit incorrectly applied immediately'
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on unprivileged update'
+    assert_equal before_edits + 1, after_edits, 'No SuggestedEdit created on unprivileged update'
+  end
+
+  test 'update rejects no change edit' do
+    sign_in users(:standard_user)
+    post = posts(:question_one)
+    before_history = PostHistory.where(post: post).count
+    patch :update, params: { id: post.id,
+                             post: { title: post.title, body_markdown: post.body_markdown,
+                                     tags_cache: post.tags_cache } }
+    after_history = PostHistory.where(post: posts(:question_one)).count
+    assert_response 302
+    assert_redirected_to post_path(posts(:question_one))
+    assert_not_nil assigns(:post)
+    assert_not_nil flash[:danger]
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on no-change update'
   end
 
-  test 'should allow signed in user to write post' do
+  test 'cannot update locked post' do
     sign_in users(:standard_user)
-    get :new, params: { category_id: categories(:main).id, post_type_id: post_types(:question).id }
+    before_history = PostHistory.where(post: posts(:locked)).count
+    patch :update, params: { id: posts(:locked).id,
+                             post: { title: sample.edit.title, body_markdown: sample.edit.body_markdown,
+                                     tags_cache: sample.edit.tags_cache } }
+    after_history = PostHistory.where(post: posts(:locked)).count
+    assert_response 401
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on update'
+  end
+
+  # Close
+
+  test 'can close question' do
+    sign_in users(:closer)
+    before_history = PostHistory.where(post: posts(:question_one)).count
+    post :close, params: { id: posts(:question_one).id, reason_id: close_reasons(:not_good).id }
+    after_history = PostHistory.where(post: posts(:question_one)).count
     assert_response 200
     assert_not_nil assigns(:post)
-    assert_equal categories(:main).id, assigns(:post).category_id
-    assert_equal post_types(:question).id, assigns(:post).post_type_id
+    assert_equal before_history + 1, after_history, 'PostHistory event not created on closure'
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'success', JSON.parse(response.body)['status']
+  end
+
+  test 'close requires authentication' do
+    post :close, params: { id: posts(:question_one).id, reason_id: close_reasons(:not_good).id }
+    assert_response 302
+    assert_redirected_to new_user_session_path
+  end
+
+  test 'unprivileged user cannot close' do
+    sign_in users(:standard_user)
+    before_history = PostHistory.where(post: posts(:question_one)).count
+    post :close, params: { id: posts(:question_one).id, reason_id: close_reasons(:not_good).id }
+    after_history = PostHistory.where(post: posts(:question_one)).count
+    assert_response 403
+    assert_not_nil assigns(:post)
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on closure'
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'failed', JSON.parse(response.body)['status']
+  end
+
+  test 'cannot close a closed post' do
+    sign_in users(:closer)
+    before_history = PostHistory.where(post: posts(:closed)).count
+    post :close, params: { id: posts(:closed).id, reason_id: close_reasons(:not_good).id }
+    after_history = PostHistory.where(post: posts(:closed)).count
+    assert_response 400
+    assert_not_nil assigns(:post)
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on closure'
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'failed', JSON.parse(response.body)['status']
+  end
+
+  test 'close rejects nonexistent close reason' do
+    sign_in users(:closer)
+    before_history = PostHistory.where(post: posts(:question_one)).count
+    post :close, params: { id: posts(:question_one).id, reason_id: -999 }
+    after_history = PostHistory.where(post: posts(:question_one)).count
+    assert_response 404
+    assert_not_nil assigns(:post)
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on closure'
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'failed', JSON.parse(response.body)['status']
+  end
+
+  test 'close ensures other post exists if reason requires it' do
+    sign_in users(:closer)
+    before_history = PostHistory.where(post: posts(:question_one)).count
+    post :close, params: { id: posts(:question_one).id, reason_id: close_reasons(:duplicate) }
+    after_history = PostHistory.where(post: posts(:question_one)).count
+    assert_response 400
+    assert_not_nil assigns(:post)
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on closure'
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'failed', JSON.parse(response.body)['status']
+  end
+
+  test 'cannot close a locked post' do
+    sign_in users(:closer)
+    before_history = PostHistory.where(post: posts(:locked)).count
+    post :close, params: { id: posts(:locked).id, reason_id: close_reasons(:not_good).id }
+    after_history = PostHistory.where(post: posts(:locked)).count
+    assert_response 401
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on close'
   end
 
-  test 'should allow signed in user to create post' do
+  # Reopen
+
+  test 'can reopen question' do
+    sign_in users(:closer)
+    before_history = PostHistory.where(post: posts(:closed)).count
+    post :reopen, params: { id: posts(:closed).id }
+    after_history = PostHistory.where(post: posts(:closed)).count
+    assert_response 302
+    assert_redirected_to post_path(posts(:closed))
+    assert_nil flash[:danger]
+    assert_equal before_history + 1, after_history, 'PostHistory event not created on reopen'
+  end
+
+  test 'reopen requires authentication' do
+    post :reopen, params: { id: posts(:closed).id }
+    assert_response 302
+    assert_redirected_to new_user_session_path
+  end
+
+  test 'unprivileged user cannot reopen' do
     sign_in users(:standard_user)
-    post :create, params: { category_id: categories(:main).id, post_type_id: post_types(:question).id,
-                            post: { body_markdown: 'ABCD EFGH IJKL MNOP QRST UVWX YZ', title: 'ABCD EFGH IJKL M',
-                                    tags_cache: ['discussion', 'support', 'bug', 'feature-request'],
-                                    license_id: licenses(:cc_by_sa).id } }
+    before_history = PostHistory.where(post: posts(:closed)).count
+    post :reopen, params: { id: posts(:closed).id }
+    after_history = PostHistory.where(post: posts(:closed)).count
+    assert_response 302
+    assert_redirected_to post_path(posts(:closed))
+    assert_not_nil flash[:danger]
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on reopen'
+  end
+
+  test 'cannot reopen an open post' do
+    sign_in users(:closer)
+    before_history = PostHistory.where(post: posts(:question_one)).count
+    post :reopen, params: { id: posts(:question_one).id }
+    after_history = PostHistory.where(post: posts(:question_one)).count
+    assert_response 302
+    assert_redirected_to post_path(posts(:question_one))
+    assert_not_nil flash[:danger]
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on reopen'
+  end
+
+  test 'cannot reopen a locked post' do
+    sign_in users(:closer)
+    before_history = PostHistory.where(post: posts(:locked)).count
+    post :reopen, params: { id: posts(:locked).id }
+    after_history = PostHistory.where(post: posts(:locked)).count
+    assert_response 401
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on reopen'
+  end
+
+  # Delete
+
+  test 'can delete post' do
+    sign_in users(:deleter)
+    before_history = PostHistory.where(post: posts(:question_two)).count
+    post :delete, params: { id: posts(:question_two).id }
+    after_history = PostHistory.where(post: posts(:question_two)).count
+    assert_response 302
+    assert_redirected_to post_path(assigns(:post))
+    assert_nil flash[:danger]
+    assert_equal before_history + 1, after_history, 'PostHistory event not created on deletion'
+  end
+
+  test 'delete requires authentication' do
+    post :delete, params: { id: posts(:question_one).id }
+    assert_response 302
+    assert_redirected_to new_user_session_path
+  end
+
+  test 'unprivileged user cannot delete' do
+    sign_in users(:closer)
+    before_history = PostHistory.where(post: posts(:question_one)).count
+    post :delete, params: { id: posts(:question_one).id }
+    after_history = PostHistory.where(post: posts(:question_one)).count
+    assert_response 302
+    assert_redirected_to post_path(assigns(:post))
+    assert_not_nil flash[:danger]
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion'
+  end
+
+  test 'cannot delete a post with responses' do
+    sign_in users(:deleter)
+    before_history = PostHistory.where(post: posts(:question_one)).count
+    post :delete, params: { id: posts(:question_one).id }
+    after_history = PostHistory.where(post: posts(:question_one)).count
+    assert_response 302
+    assert_redirected_to post_path(assigns(:post))
+    assert_not_nil flash[:danger]
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion'
+  end
+
+  test 'cannot delete a deleted post' do
+    sign_in users(:deleter)
+    before_history = PostHistory.where(post: posts(:deleted)).count
+    post :delete, params: { id: posts(:deleted).id }
+    after_history = PostHistory.where(post: posts(:deleted)).count
+    assert_response 302
+    assert_redirected_to post_path(assigns(:post))
+    assert_not_nil flash[:danger]
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion'
+  end
+
+  test 'cannot delete a locked post' do
+    sign_in users(:deleter)
+    before_history = PostHistory.where(post: posts(:locked)).count
+    post :delete, params: { id: posts(:locked).id }
+    after_history = PostHistory.where(post: posts(:locked)).count
+    assert_response 401
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion'
+  end
+
+  test 'delete ensures all children are deleted' do
+    sign_in users(:deleter)
+    before_history = PostHistory.where(post_id: posts(:bad_answers).children.map(&:id)).count
+    post :delete, params: { id: posts(:bad_answers).id }
+    after_history = PostHistory.where(post_id: posts(:bad_answers).children.map(&:id)).count
+    assert_response 302
+    assert_redirected_to post_path(assigns(:post))
+    assert_nil flash[:danger]
+    assert assigns(:post).children.all?(&:deleted), 'Answers not deleted with question'
+    assert_equal before_history + posts(:bad_answers).children.count, after_history,
+                 'Answer PostHistory events not created on question deletion'
+  end
+
+  # Restore
+
+  test 'can restore post' do
+    sign_in users(:deleter)
+    before_history = PostHistory.where(post: posts(:deleted)).count
+    post :restore, params: { id: posts(:deleted).id }
+    after_history = PostHistory.where(post: posts(:deleted)).count
+    assert_response 302
+    assert_redirected_to post_path(assigns(:post))
+    assert_nil flash[:danger]
+    assert_equal before_history + 1, after_history, 'PostHistory event not created on deletion'
+  end
+
+  test 'restore requires authentication' do
+    post :restore, params: { id: posts(:deleted).id }
     assert_response 302
+    assert_redirected_to new_user_session_path
+  end
+
+  test 'unprivileged user cannot restore' do
+    sign_in users(:closer)
+    before_history = PostHistory.where(post: posts(:deleted)).count
+    post :restore, params: { id: posts(:deleted).id }
+    after_history = PostHistory.where(post: posts(:deleted)).count
+    assert_response 302
+    assert_redirected_to post_path(assigns(:post))
+    assert_not_nil flash[:danger]
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion'
+  end
+
+  test 'cannot restore a post deleted by a moderator' do
+    sign_in users(:deleter)
+    before_history = PostHistory.where(post: posts(:deleted_mod)).count
+    post :restore, params: { id: posts(:deleted_mod).id }
+    after_history = PostHistory.where(post: posts(:deleted_mod)).count
+    assert_response 302
+    assert_redirected_to post_path(assigns(:post))
+    assert_not_nil flash[:danger]
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion'
+  end
+
+  test 'cannot restore a restored post' do
+    sign_in users(:deleter)
+    before_history = PostHistory.where(post: posts(:question_one)).count
+    post :restore, params: { id: posts(:question_one).id }
+    after_history = PostHistory.where(post: posts(:question_one)).count
+    assert_response 302
+    assert_redirected_to post_path(assigns(:post))
+    assert_not_nil flash[:danger]
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion'
+  end
+
+  test 'cannot restore a locked post' do
+    sign_in users(:deleter)
+    before_history = PostHistory.where(post: posts(:locked)).count
+    post :restore, params: { id: posts(:locked).id }
+    after_history = PostHistory.where(post: posts(:locked)).count
+    assert_response 401
+    assert_equal before_history, after_history, 'PostHistory event incorrectly created on deletion'
+  end
+
+  test 'restore brings back all answers deleted after question' do
+    sign_in users(:deleter)
+    deleted_at = posts(:deleted).deleted_at
+    children = posts(:deleted).children.where('deleted_at >= ?', deleted_at)
+    children_count = children.count
+    before_history = PostHistory.where(post_id: children.where('deleted_at >= ?', deleted_at)).count
+    post :restore, params: { id: posts(:deleted).id }
+    after_history = PostHistory.where(post_id: children.where('deleted_at >= ?', deleted_at)).count
+    assert_response 302
+    assert_redirected_to post_path(assigns(:post))
+    assert_nil flash[:danger]
+    assert_equal before_history + children_count, after_history,
+                 'Answer PostHistory events not created on question restore'
+  end
+
+  # Toggle comments
+
+  test 'can toggle comments' do
+    sign_in users(:moderator)
+    post :toggle_comments, params: { id: posts(:question_one).id }
+    assert_response 200
     assert_not_nil assigns(:post)
-    assert_empty assigns(:post).errors.full_messages
-    assert_redirected_to question_path(assigns(:post))
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'success', JSON.parse(response.body)['status']
+    assert assigns(:post).comments_disabled
+  end
+
+  test 'toggle comments requires authentication' do
+    post :toggle_comments, params: { id: posts(:question_one).id }
+    assert_response 302
+    assert_redirected_to new_user_session_path
   end
 
-  test 'should ensure community_user is created while posting' do
+  test 'regular users cannot toggle comments' do
     sign_in users(:standard_user)
-    post :create, params: { category_id: categories(:main).id, post_type_id: post_types(:question).id,
-                            post: { body_markdown: 'ABCD EFGH IJKL MNOP QRST UVWX YZ', title: 'ABCD EFGH IJKL M',
-                                    tags_cache: ['discussion', 'support', 'bug', 'feature-request'],
-                                    license_id: licenses(:cc_by_sa).id } }
+    post :toggle_comments, params: { id: posts(:question_one).id }
+    assert_response 404
+    assert_not_nil assigns(:post)
+    assert_not assigns(:post).comments_disabled
+  end
+
+  test 'specifying delete all results in comments being deleted' do
+    sign_in users(:moderator)
+    post :toggle_comments, params: { id: posts(:question_one).id, delete_all_comments: true }
+    assert_response 200
+    assert_not_nil assigns(:post)
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'success', JSON.parse(response.body)['status']
+    assert assigns(:post).comments_disabled
+    assert assigns(:post).comments.all?(&:deleted?)
+  end
+
+  # Lock
+
+  test 'can lock post' do
+    sign_in users(:deleter)
+    post :lock, params: { id: posts(:question_one).id, format: :json }
+    assert_response 200
     assert_not_nil assigns(:post)
-    assert_not_nil assigns(:post).user
-    assert_not_nil assigns(:post).user.community_user
+    assert assigns(:post).locked_until <= 7.days.from_now
+    assert assigns(:post).locked_until >= 7.days.from_now - 1.minute
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'success', JSON.parse(response.body)['status']
   end
 
-  test 'should prevent user with insufficient trust level posting when category requires higher' do
+  test 'lock requires authentication' do
+    post :lock, params: { id: posts(:question_one).id }
+    assert_response 302
+    assert_redirected_to new_user_session_path
+  end
+
+  test 'unprivileged user cannot lock' do
     sign_in users(:standard_user)
-    post :create, params: { category_id: categories(:high_trust).id, post_type_id: post_types(:question).id,
-                            post: { body_markdown: 'ABCD EFGH IJKL MNOP QRST UVWX YZ', title: 'ABCD EFGH IJKL M',
-                                    tags_cache: ['discussion', 'support', 'bug', 'feature-request'],
-                                    license_id: licenses(:cc_by_sa).id } }
-    assert_response 403
+    post :lock, params: { id: posts(:question_one).id, format: :json }
+    assert_response 404
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'failed', JSON.parse(response.body)['status']
+  end
+
+  test 'cannot lock locked post' do
+    sign_in users(:deleter)
+    post :lock, params: { id: posts(:locked).id, format: :json }
+    assert_response 404
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'failed', JSON.parse(response.body)['status']
+  end
+
+  test 'cannot lock longer than 30 days' do
+    sign_in users(:deleter)
+    post :lock, params: { id: posts(:question_one).id, length: 60, format: :json }
+    assert_response 200
     assert_not_nil assigns(:post)
-    assert_equal true, assigns(:post).errors.any?
-    assert_equal true, assigns(:post).errors.full_messages[0].start_with?("You don't have a high enough trust level")
+    assert assigns(:post).locked_until <= 30.days.from_now
+    assert assigns(:post).locked_until >= 30.days.from_now - 1.minute
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'success', JSON.parse(response.body)['status']
   end
 
-  test 'should change category' do
+  test 'moderator can lock longer than 30 days' do
+    sign_in users(:moderator)
+    post :lock, params: { id: posts(:question_one).id, length: 60, format: :json }
+    assert_response 200
+    assert_not_nil assigns(:post)
+    assert assigns(:post).locked_until <= 60.days.from_now
+    assert assigns(:post).locked_until >= 60.days.from_now - 1.minute
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'success', JSON.parse(response.body)['status']
+  end
+
+  test 'moderator can lock indefinitely' do
+    sign_in users(:moderator)
+    post :lock, params: { id: posts(:question_one).id, format: :json }
+    assert_response 200
+    assert_not_nil assigns(:post)
+    assert_nil assigns(:post).locked_until
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'success', JSON.parse(response.body)['status']
+  end
+
+  # Unlock
+
+  test 'can unlock post' do
     sign_in users(:deleter)
-    post :change_category, params: { id: posts(:article_one).id, target_id: categories(:articles_only).id }
+    posts(:locked).update(locked_until: 2.days.from_now)
+    post :unlock, params: { id: posts(:locked).id, format: :json }
     assert_response 200
     assert_not_nil assigns(:post)
-    assert_not_nil assigns(:target)
     assert_nothing_raised do
       JSON.parse(response.body)
     end
-    assert_equal categories(:articles_only).id, assigns(:post).category_id
+    assert_equal 'success', JSON.parse(response.body)['status']
   end
 
-  test 'should deny change category to unprivileged' do
+  test 'unlock requires authentication' do
+    post :unlock, params: { id: posts(:locked).id }
+    assert_response 302
+    assert_redirected_to new_user_session_path
+  end
+
+  test 'unprivileged user cannot unlock' do
     sign_in users(:standard_user)
-    post :change_category, params: { id: posts(:article_one).id, target_id: categories(:articles_only).id }
-    assert_response 403
+    post :unlock, params: { id: posts(:locked).id, format: :json }
+    assert_response 404
     assert_nothing_raised do
       JSON.parse(response.body)
     end
-    assert_equal ["You don't have permission to make that change."], JSON.parse(response.body)['errors']
+    assert_equal 'failed', JSON.parse(response.body)['status']
   end
 
-  test 'should refuse to change category of wrong post type' do
+  test 'cannot unlock unlocked post' do
     sign_in users(:deleter)
-    post :change_category, params: { id: posts(:question_one).id, target_id: categories(:articles_only).id }
-    assert_response 409
+    post :unlock, params: { id: posts(:question_one).id, format: :json }
+    assert_response 404
     assert_nothing_raised do
       JSON.parse(response.body)
     end
-    assert_equal ["This post type is not allowed in the #{categories(:articles_only).name} category."],
-                 JSON.parse(response.body)['errors']
+    assert_equal 'failed', JSON.parse(response.body)['status']
+  end
+
+  test 'cannot unlock post locked by moderator' do
+    sign_in users(:deleter)
+    posts(:locked_mod).update(locked_until: 2.days.from_now)
+    post :unlock, params: { id: posts(:locked_mod).id, format: :json }
+    assert_response 404
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal 'failed', JSON.parse(response.body)['status']
+    assert_equal ['locked_by_mod'], JSON.parse(response.body)['errors']
+  end
+
+  # Feature
+
+  test 'can feature post' do
+    sign_in users(:moderator)
+    before_audits = AuditLog.count
+    post :feature, params: { id: posts(:question_one).id }
+    assert_response 200
+    assert_not_nil assigns(:post)
+    assert_not_nil assigns(:link).id
+    assert_equal before_audits + 1, AuditLog.count, 'AuditLog not created on post feature'
+  end
+
+  test 'feature requires authentication' do
+    post :feature, params: { id: posts(:question_one).id }
+    assert_response 302
+    assert_redirected_to new_user_session_path
+  end
+
+  test 'regular user cannot feature' do
+    sign_in users(:deleter)
+    post :feature, params: { id: posts(:question_one).id, format: :json }
+    assert_response 404
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal ['no_privilege'], JSON.parse(response.body)['errors']
+  end
+
+  # Save draft
+
+  test 'can save draft' do
+    sign_in users(:standard_user)
+    post :save_draft, params: { path: 'test', post: 'test' }
+    assert_response 200
+    assert_nothing_raised do
+      JSON.parse(response.body)
+    end
+    assert_equal "saved_post.#{users(:standard_user).id}.test", JSON.parse(response.body)['key']
+    assert_equal 'test', RequestContext.redis.get(JSON.parse(response.body)['key'])
+  end
+
+  # Delete draft
+
+  test 'can delete draft' do
+    sign_in users(:standard_user)
+    post :delete_draft, params: { path: 'test' }
+    assert_response 200
   end
 end
diff --git a/test/controllers/questions_controller_test.rb b/test/controllers/questions_controller_test.rb
index 0cf44210f05184eb5a142c79e0fdb2cbefcb722b..5164c708ef072ffa37b48bdd24dfc1f81e27968a 100644
--- a/test/controllers/questions_controller_test.rb
+++ b/test/controllers/questions_controller_test.rb
@@ -3,185 +3,4 @@ require 'test_helper'
 class QuestionsControllerTest < ActionController::TestCase
   include Devise::Test::ControllerHelpers
   include ApplicationTestHelper
-
-  test 'should get index' do
-    get :index
-    assert_not_nil assigns(:questions)
-    assert_equal Question.undeleted.count, assigns(:questions).size
-    assert_response(200)
-  end
-
-  test 'should get show question page' do
-    get :show, params: { id: posts(:question_one).id }
-    assert_not_nil assigns(:question)
-    assert_not_nil assigns(:answers)
-    assert_response(200)
-  end
-
-  test 'should get show question page with deleted question' do
-    sign_in users(:deleter)
-    get :show, params: { id: posts(:deleted).id }
-    assert_not_nil assigns(:question)
-    assert_not_nil assigns(:answers)
-    assert_response(200)
-  end
-
-  test 'should get show question page with closed question' do
-    sign_in users(:closer)
-    get :show, params: { id: posts(:closed).id }
-    assert_response 200
-    assert_not_nil assigns(:question)
-    assert_not_nil assigns(:answers)
-  end
-
-  test 'should get tagged page' do
-    get :tagged, params: { tag: 'discussion', tag_set: tag_sets(:main).id }
-    assert_not_nil assigns(:questions)
-    assert_response(200)
-  end
-
-  test 'should get edit question page' do
-    sign_in users(:editor)
-    get :edit, params: { id: posts(:question_one).id }
-    assert_not_nil assigns(:question)
-    assert_response(200)
-  end
-
-  test 'should update existing question' do
-    sign_in users(:editor)
-    patch :update, params: { id: posts(:question_one).id, question: { title: 'ABCDEF GHIJKL MNOPQR',
-                                                                      body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ',
-                                                                      tags_cache: ['discussion', 'support'] } }
-    assert_not_nil assigns(:question)
-    assert_array_equal ['discussion', 'support'], assigns(:question).tags_cache
-    assert_array_equal ['discussion', 'support'], assigns(:question).tags.map(&:name)
-    assert_response(302)
-  end
-
-  test 'should mark question deleted' do
-    sign_in users(:deleter)
-    delete :destroy, params: { id: posts(:question_one).id }
-    assert_not_nil assigns(:question)
-    assert_equal true, assigns(:question).deleted
-    assert_response(302)
-  end
-
-  test 'should mark question undeleted' do
-    sign_in users(:deleter)
-    delete :undelete, params: { id: posts(:question_one).id }
-    assert_not_nil assigns(:question)
-    assert_equal false, assigns(:question).deleted
-    assert_response(302)
-  end
-
-  test 'should require authentication to get edit question page' do
-    sign_out :user
-    get :edit, params: { id: posts(:question_one).id }
-    assert_response(302)
-  end
-
-  test 'should require authentication to update existing question' do
-    sign_out :user
-    patch :update, params: { id: posts(:question_one).id }
-    assert_response(302)
-  end
-
-  test 'should require authentication to mark question deleted' do
-    sign_out :user
-    delete :destroy, params: { id: posts(:question_one).id }
-    assert_response(302)
-  end
-
-  test 'should require authentication to undelete question' do
-    sign_out :user
-    delete :undelete, params: { id: posts(:question_one).id }
-    assert_response(302)
-  end
-
-  test 'should require delete privileges to mark question deleted' do
-    sign_in users(:editor)
-    delete :destroy, params: { id: posts(:question_one).id }
-    assert_response(302)
-    assert_not_nil flash[:danger]
-  end
-
-  test 'should require delete privileges to undelete question' do
-    sign_in users(:editor)
-    delete :undelete, params: { id: posts(:question_one).id }
-    assert_response(302)
-    assert_not_nil flash[:danger]
-  end
-
-  test 'should require viewdeleted privileges to view deleted question' do
-    sign_in users(:editor)
-    get :show, params: { id: posts(:deleted).id }
-    assert_response(403)
-  end
-
-  test 'should close question' do
-    sign_in users(:closer)
-    patch :close, params: { id: posts(:question_one).id, reason_id: close_reasons(:not_good).id }
-    assert_not_nil assigns(:question)
-    assert_equal true, assigns(:question).closed
-    assert_nothing_raised do
-      JSON.parse(response.body)
-    end
-    assert_equal 'success', JSON.parse(response.body)['status']
-    assert_response(200)
-  end
-
-  test 'should reopen question' do
-    sign_in users(:closer)
-    patch :reopen, params: { id: posts(:closed).id }
-    assert_not_nil assigns(:question)
-    assert_equal false, assigns(:question).closed
-    assert_response(302)
-  end
-
-  test 'should require authentication to close question' do
-    sign_out :user
-    patch :close, params: { id: posts(:question_one).id }
-    assert_response(302)
-  end
-
-  test 'should require authentication to reopen question' do
-    sign_out :user
-    patch :reopen, params: { id: posts(:closed).id }
-    assert_response(302)
-  end
-
-  test 'should require privileges to close question' do
-    sign_in users(:standard_user)
-    patch :close, params: { id: posts(:question_one).id }
-    assert_equal false, assigns(:question).closed
-    assert_response(403)
-  end
-
-  test 'should require privileges to reopen question' do
-    sign_in users(:standard_user)
-    patch :reopen, params: { id: posts(:closed).id }
-    assert_equal true, assigns(:question).closed
-    assert_response(302)
-  end
-
-  test 'should prevent closed questions being closed' do
-    sign_in users(:closer)
-    patch :close, params: { id: posts(:closed).id }
-    assert_equal true, assigns(:question).closed
-    assert_response(400)
-  end
-
-  test 'should prevent open questions being reopened' do
-    sign_in users(:closer)
-    patch :reopen, params: { id: posts(:question_one).id }
-    assert_equal false, assigns(:question).closed
-    assert_not_nil flash[:danger]
-    assert_response(302)
-  end
-
-  test 'should prevent using questions routes for articles' do
-    sign_in users(:deleter) # deliberate; catch ViewDeleted using unscoped
-    get :show, params: { id: posts(:article_one).id }
-    assert_response 404
-  end
 end
diff --git a/test/controllers/subscriptions_controller_test.rb b/test/controllers/subscriptions_controller_test.rb
index d9afb8f497e183453b4e25b42180316836c9f11c..898730b31091b4f05089e73d285b8878fb784713 100644
--- a/test/controllers/subscriptions_controller_test.rb
+++ b/test/controllers/subscriptions_controller_test.rb
@@ -20,8 +20,8 @@ class SubscriptionsControllerTest < ActionController::TestCase
     get :index
     assert_response 200
     assert_not_nil assigns(:subscriptions)
-    assert !assigns(:subscriptions).empty?,
-           '@subscriptions instance variable expected size > 0, got <= 0'
+    assert_not assigns(:subscriptions).empty?,
+               '@subscriptions instance variable expected size > 0, got <= 0'
   end
 
   test 'should get new when logged in' do
diff --git a/test/fixtures/categories.yml b/test/fixtures/categories.yml
index 8110d53a851b764b5d80d5ec6e3c5183dbd92703..5669e36b4f47099b43bdd50227c02333a0817546 100644
--- a/test/fixtures/categories.yml
+++ b/test/fixtures/categories.yml
@@ -36,7 +36,7 @@ high_trust:
     - question
     - answer
   tag_set: main
-  min_trust_level: 6
+  min_trust_level: 3
   license: cc_by_sa
 
 admin_only:
diff --git a/test/fixtures/post_types.yml b/test/fixtures/post_types.yml
index 71c578bf15cd658dd8bb260ec97eaa0f725e874c..dea8f1923fecae1422e2a244f1099156f06cd0ef 100644
--- a/test/fixtures/post_types.yml
+++ b/test/fixtures/post_types.yml
@@ -1,14 +1,77 @@
 question:
   name: Question
+  description: ~
+  has_answers: true
+  has_votes: true
+  has_tags: true
+  has_parent: false
+  has_category: true
+  has_license: true
+  is_public_editable: true
+  is_closeable: true
+  is_top_level: true
 
 answer:
   name: Answer
+  description: ~
+  has_answers: false
+  has_votes: true
+  has_tags: false
+  has_parent: true
+  has_category: true
+  has_license: true
+  is_public_editable: true
+  is_closeable: false
+  is_top_level: false
 
 article:
   name: Article
+  description: ~
+  has_answers: false
+  has_votes: true
+  has_tags: true
+  has_parent: false
+  has_category: true
+  has_license: true
+  is_public_editable: true
+  is_closeable: false
+  is_top_level: true
 
 policy_doc:
   name: PolicyDoc
+  description: ~
+  has_answers: false
+  has_votes: false
+  has_tags: false
+  has_parent: false
+  has_category: false
+  has_license: false
+  is_public_editable: false
+  is_closeable: false
+  is_top_level: false
 
 help_doc:
-  name: HelpDoc
\ No newline at end of file
+  name: HelpDoc
+  description: ~
+  has_answers: false
+  has_votes: false
+  has_tags: false
+  has_parent: false
+  has_category: false
+  has_license: false
+  is_public_editable: false
+  is_closeable: false
+  is_top_level: false
+
+blog_post:
+  name: BlogPost
+  description: ~
+  has_answers: false
+  has_votes: true
+  has_tags: true
+  has_parent: false
+  has_category: true
+  has_license: true
+  is_public_editable: false
+  is_closeable: false
+  is_top_level: true
\ No newline at end of file
diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml
index 9781f1877fdf599b79e799c2944f771ba5626923..54e50a21a3d00b8259dea8ec0b6219eff0a82e25 100644
--- a/test/fixtures/posts.yml
+++ b/test/fixtures/posts.yml
@@ -40,6 +40,27 @@ question_two:
   upvote_count: 0
   downvote_count: 0
 
+bad_answers:
+  post_type: question
+  title: Q1 ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  tags_cache:
+    - discussion
+    - support
+    - bug
+  tags:
+    - discussion
+    - support
+    - bug
+  score: 0.5
+  user: standard_user
+  community: sample
+  category: main
+  license: cc_by_sa
+  upvote_count: 0
+  downvote_count: 0
+
 deleted:
   post_type: question
   title: Q3D ZY XWVUTS RQPONM LKJIHG FEDCBA
@@ -64,6 +85,30 @@ deleted:
   upvote_count: 0
   downvote_count: 0
 
+deleted_mod:
+  post_type: question
+  title: Q3D ZY XWVUTS RQPONM LKJIHG FEDCBA
+  body: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA
+  body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA
+  tags_cache:
+    - discussion
+    - support
+    - bug
+  tags:
+    - discussion
+    - support
+    - bug
+  score: 0.5
+  user: standard_user
+  deleted: true
+  deleted_at: 2019-01-01T00:00:00.000000Z
+  deleted_by: moderator
+  community: sample
+  category: main
+  license: cc_by_sa
+  upvote_count: 0
+  downvote_count: 0
+
 closed:
   post_type: question
   title: Q4C ABCDEF GHIJKL MNOPQR STUVWX YZ
@@ -88,6 +133,56 @@ closed:
   upvote_count: 0
   downvote_count: 0
 
+locked:
+  post_type: question
+  title: Q4C ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA
+  tags_cache:
+    - discussion
+    - support
+    - bug
+  tags:
+    - discussion
+    - support
+    - bug
+  score: 0.5
+  locked: true
+  locked_by: deleter
+  locked_at: 2019-01-01T00:00:00.000000Z
+  locked_until: 2020-01-01T00:00:00.000000Z
+  user: standard_user
+  community: sample
+  category: main
+  license: cc_by_sa
+  upvote_count: 0
+  downvote_count: 0
+
+locked_mod:
+  post_type: question
+  title: LM ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA
+  tags_cache:
+    - discussion
+    - support
+    - bug
+  tags:
+    - discussion
+    - support
+    - bug
+  score: 0.5
+  locked: true
+  locked_by: moderator
+  locked_at: 2019-01-01T00:00:00.000000Z
+  locked_until: 2020-01-01T00:00:00.000000Z
+  user: standard_user
+  community: sample
+  category: main
+  license: cc_by_sa
+  upvote_count: 0
+  downvote_count: 0
+
 answer_one:
   post_type: answer
   body: A1 ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
@@ -114,6 +209,19 @@ answer_two:
   upvote_count: 0
   downvote_count: 0
 
+bad_answer:
+  post_type: answer
+  body: A2 ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body_markdown: ZY XWVUTS RQPONM LKJIHG FEDCBA ZY XWVUTS RQPONM LKJIHG FEDCBA
+  score: 0.4
+  parent: question_one
+  user: editor
+  community: sample
+  category: main
+  license: cc_by_sa
+  upvote_count: 0
+  downvote_count: 1
+
 really_old_answer:
   post_type: answer
   body: A3RO ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
@@ -208,3 +316,52 @@ deleted_article:
   deleted_by: deleter
   upvote_count: 0
   downvote_count: 0
+
+help_article:
+  post_type: help_doc
+  body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  user: system
+  community: sample
+  help_category: Site Information
+  help_ordering: 99
+  doc_slug: sample
+
+mod_help_article:
+  post_type: help_doc
+  body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  user: system
+  community: sample
+  help_category: $Moderator
+  help_ordering: 99
+  doc_slug: sample-mod
+
+disabled_help_article:
+  post_type: help_doc
+  body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  user: system
+  community: sample
+  help_category: $Disabled
+  help_ordering: 99
+  doc_slug: sample-disable
+
+blog_post:
+  post_type: blog_post
+  title: B1 ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ
+  tags_cache:
+    - discussion
+    - support
+    - bug
+  tags:
+    - discussion
+    - support
+    - bug
+  user: closer
+  community: sample
+  category: main
+  license: cc_by_sa
+
diff --git a/test/fixtures/user_abilities.yml b/test/fixtures/user_abilities.yml
index 0cbcccbe2b9316ab8bac1b363e6b4ef1c6f15eb7..6d4b9dd5ec1b1e896aaf5a0ec2ea92fd31b15781 100644
--- a/test/fixtures/user_abilities.yml
+++ b/test/fixtures/user_abilities.yml
@@ -1,10 +1,3 @@
-# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
-
-# This model initially had no columns defined. If you add columns to the
-# model remove the '{}' from the fixture names and add the columns immediately
-# below each fixture, per the syntax in the comments below
-#
-
 stu_eo:
   community_user: sample_standard_user
   ability: everyone
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
index 3bbd83aa9950307b2ccc8e82ff07b76c03ef3ac4..b3b65427bca634104cb3728508140c486d7cfec1 100644
--- a/test/fixtures/users.yml
+++ b/test/fixtures/users.yml
@@ -75,3 +75,22 @@ global_admin:
   is_global_admin: true
   is_global_moderator: false
   confirmed_at: 2020-01-01T00:00:00.000000Z
+
+no_community_user:
+  email: no_community_user@qpixel-test.net
+  encrypted_password: abcdefghijklmnopqrstuvwxyz
+  sign_in_count: 1337
+  username: no_community_user
+  is_global_admin: false
+  is_global_moderator: false
+  confirmed_at: 2020-01-01T00:00:00.000000Z
+
+system:
+  id: -99
+  email: system@qpixel-test.net
+  encrypted_password: abcdefghijklmnopqrstuvwxyz
+  sign_in_count: 1337
+  username: system
+  is_global_admin: true
+  is_global_moderator: true
+  confirmed_at: 2020-01-01T00:00:00.000000Z
diff --git a/test/models/site_setting_test.rb b/test/models/site_setting_test.rb
index e13bd026f76bcddb0919ee5ffef5009c3e62a670..053c540094dd6efda9c8172f4d6b14bbd80d3c3e 100644
--- a/test/models/site_setting_test.rb
+++ b/test/models/site_setting_test.rb
@@ -34,18 +34,18 @@ class SiteSettingTest < ActiveSupport::TestCase
 
   test 'community settings are in the default scope' do
     SiteSetting.create(community_id: RequestContext.community, name: 'test', value: 'true', value_type: 'string')
-    assert SiteSetting.where(name: 'test').exists?
+    assert SiteSetting.exists?(name: 'test')
   end
 
   test 'global settings are in the default scope' do
     SiteSetting.create(community_id: nil, name: 'test', value: 'true', value_type: 'string')
-    assert SiteSetting.where(name: 'test').exists?
+    assert SiteSetting.exists?(name: 'test')
   end
 
   test 'external community settings are not in the default scope' do
     other_community = Community.create(host: 'other', name: 'other')
     SiteSetting.create(community_id: other_community, name: 'test', value: 'true', value_type: 'string')
-    assert SiteSetting.where(name: 'test').exists?
+    assert SiteSetting.exists?(name: 'test')
   end
 
   test 'community setting takes precedence over global setting' do
diff --git a/test/models/subscription_test.rb b/test/models/subscription_test.rb
index 7fdeec1c2650d48e46d2c5e32a985f0afbfbcaa5..da38c75449f337a8600a03c913bfef4c3004c9ea 100644
--- a/test/models/subscription_test.rb
+++ b/test/models/subscription_test.rb
@@ -10,14 +10,14 @@ class SubscriptionTest < ActiveSupport::TestCase
   test 'subscription to all should return some questions' do
     questions = subscriptions(:all).questions
     assert_not_nil questions
-    assert !questions.empty?, 'No questions returned'
+    assert_not questions.empty?, 'No questions returned'
     assert questions.size <= 100, 'Too many questions returned'
   end
 
   test 'tag subscription should return only tag questions' do
     questions = subscriptions(:tag).questions
     assert_not_nil questions
-    assert !questions.empty?, 'No questions returned'
+    assert_not questions.empty?, 'No questions returned'
     assert questions.size <= 100, 'Too many questions returned'
     questions.each do |question|
       assert question.tags.map(&:name).include?(subscriptions(:tag).qualifier),
@@ -28,7 +28,7 @@ class SubscriptionTest < ActiveSupport::TestCase
   test 'user subscription should return only user questions' do
     questions = subscriptions(:user).questions
     assert_not_nil questions
-    assert !questions.empty?, 'No questions returned'
+    assert_not questions.empty?, 'No questions returned'
     assert questions.size <= 100, 'Too many questions returned'
     questions.each do |question|
       assert question.user_id == subscriptions(:user).qualifier.to_i,
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 6c11d6e003367fb0fd967d6e849a47a0dfcd8b55..cab55a4fef2de24cf658f5dd684db4eb615fb07e 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -35,6 +35,21 @@ class ActiveSupport::TestCase
       Ability.create(a.attributes.merge(community_id: community_id, id: nil))
     end
   end
+
+  def sample
+    OpenStruct.new(
+      title: 'This is a sample title',
+      body_markdown: 'This is a sample post with some **Markdown** and [a link](/).',
+      body: '<p>This is a sample post with some <b>Markdown</b> and <a href="/">a link</a></p>',
+      tags_cache: ['discussion', 'posts', 'tags'],
+      edit: OpenStruct.new(
+        title: 'This is another sample title',
+        body_markdown: 'This is a sample post with some more **Markdown** and [a link](/).',
+        body: '<p>This is a sample post with some more <b>Markdown</b> and <a href="/">a link</a></p>',
+        tags_cache: ['discussion', 'posts', 'tags', 'edits']
+      )
+    )
+  end
 end
 
 class ActionController::TestCase