Skip to content

Modifying thredded for a multi tenant app

Ben W. Brumfield edited this page Dec 12, 2022 · 3 revisions

Thredded is a great Ruby on Rails engine to support discussion forums within a Rails application. It's got loads of features, from basic messageboard functionality to moderation, to LaTeX mark-up. But it's designed to host one set of messageboards within an application, presenting a few problems for applications that want to present the thredded interface to several different tenants, independent of each other. Here's what we did to use thredded to power messageboards for each project within FromThePage

Connect a messageboard group to your application model

In our application, we we wanted the messageboard experience to be scoped to a single project -- a model we call a Collection. The easiest way to accomplish this was to connect the Collection model to a Thredded::MessageboardGroup record, since thredded already has controllers and views for showing multiple message boards within the context of a single MessageboardGroup.

First, add the foreign key to your application's model:

class AddMessageboardGroupToCollection < ActiveRecord::Migration[6.0]
  def change
    add_reference :collections, :thredded_messageboard_group, null: true, foreign_key: true
  end
end

This also requires an association within your model

  belongs_to :messageboard_group, class_name: 'Thredded::MessageboardGroup', foreign_key: 'thredded_messageboard_group_id', optional: true

Create default message boards and associate with your model

Since we wanted message boards to be an optional feature of our application (to be toggled on or off by administrative users on the Collection level), we also needed to add a flag for enabling/disabling message boards:

class AddMesssageboardsEnabledToCollection < ActiveRecord::Migration[6.0]
  def change
    add_column :collections, :messageboards_enabled, :boolean
  end
end

Now we're ready to create message board groups when users enable the message board functionality. We wanted to create a couple of default message boards when this happens, as well.

  def enable_messageboards
    if self.messageboard_group.nil?
      self.messageboard_group = Thredded::MessageboardGroup.create!(name: self.title)
      # now create the default messageboards
      Thredded::Messageboard.create!(name: 'General', description: 'General discussion', messageboard_group_id: self.messageboard_group.id)
      Thredded::Messageboard.create!(name: 'Help', messageboard_group_id: self.messageboard_group.id)
    end
    self.messageboards_enabled = true
    self.save!
  end

  def disable_messageboards
    self.messageboards_enabled=false
    self.save!
  end

Now we run into the first real problem using Thredded in a multi-tenant environment -- we'd like to create a "General" messageboard for each tenant, but there is a unique validation rule on messagboard names. This requires an override.

First, modify your application initializer to load overrides if you have not already.

In config/application.rb add this code:

  # load overrides for Thredded and other engines
  # config/application.rb
    overrides = "#{Rails.root}/app/overrides"
    Rails.autoloaders.main.ignore(overrides)

    config.to_prepare do
      Dir.glob("#{overrides}/**/*_override.rb").each do |override|
        load override
      end
    end

Next, add an override to the Thredded model by creating app/overrides/models/thredded/messageboard_override.rb with these contents:

Thredded::Messageboard.class_eval do
  clear_validators!
  validates :name,
            length: { within: Thredded.messageboard_name_length_range },
            presence: true
  validates :topics_count, numericality: true
  validates :position, presence: true, on: :update
end

Now you should be able to hook into the UI by updating the controller for the tenant model (app/controllers/collection_controller.rb)

  def enable_messageboards
    @collection.enable_messageboards
    redirect_to edit_collection_path(@collection.owner, @collection)
  end

  def disable_messageboards
    @collection.disable_messageboards
    redirect_to edit_collection_path(@collection.owner, @collection)
  end

and modifying your view (app/views/collection/edit.html.slim)

    h3 =t('.message_boards')
    p =t('.message_boards_description')
    -if @collection.messageboards_enabled?
      =link_to(collection_disable_messageboards_path(:collection_id => @collection.id), class: 'button')
        span =t('.disable_message_boards')
    -else
      =link_to(collection_enable_messageboards_path(:collection_id => @collection.id), class: 'button')
        span =t('.enable_message_boards')

Mounting Thredded under a tenant object

For the rest of our application, resources are mounted under a path containing the user slug and the collection slug. We'd like thredded to appear under that, so that /john-smith/first-project/forum loads Thredded within the navigation framework used by other resources under /john-smith/first-project.

First, mount Thredded under the resource in config/routes.rb

  scope ':user_slug' do
    scope ':collection_id' do
      mount Thredded::Engine => '/forum'
    end
  end

Next, add a reference to Thredded within your application navigation. (/app/views/shared/_collection_tabs.html.slim)

-if @collection.messageboards_enabled? && signed_in?
  -tabs +=[{\
  :name => t('.forum'),
  :selected => 14,
  :path => "#{Thredded::UrlsHelper::show_messageboard_group_path(@collection.messageboard_group, user_slug: @collection.owner.slug, collection_id: @collection.slug)}",
}]

(NB: If this is a partial rendered by your application and also by a Thredded layout, you'll need to prefix all your path helpers with main_app.)

Because our application only uses one layout to render all pages (navigation links like tabs are rendered by the views themselves, which is not recommended), we needed to render the tenant-specific navigation within a thredded-specific part of our application layout (/app/layouts/application.html/slim):

      -if content_for?(:thredded)
        =render({ :partial => '/shared/collection_tabs', :locals => { :selected => 14, :collection_id => @collection.id }})

But wait! If Thredded will be rendering URLs using the user slug and collection slug, it needs to know what those values are! We load a @collection object in /app/controllers/application_controller.rb, so after loading that we need to add this code to make the path helpers work:

if self.class.module_parent == Thredded && @collection
  Thredded::Engine.routes.default_url_options = { user_slug: @collection.owner.slug, collection_id: @collection.slug }
else
  Thredded::Engine.routes.default_url_options = { user_slug: 'nil', collection_id: 'nil' }
end

Suppressing cross-talk

Now we have a tab under our tenant object that renders thredded, with links back to the rest of our application. All we need to do is suppress cross-talk, to keep users from seeing messageboards outside the context of their tenant object. For this, we'll need to override a handful of Thredded views and controllers.

Suppress display of "Create Messageboard Group" and navigation to "All Messageboards" within these files:

  • app/views/thredded/messageboard_groups/show.html.erb
  • app/views/thredded/messageboards/_form.html.erb
  • app/views/thredded/shared/_breadcrumbs.html.erb
  • app/views/thredded/topics/search.html.erb

You'll also need to override a handful of actions on the Thredded controllers.

In app/overrides/controllers/thredded/messageboards_controller_override.rb

Thredded::MessageboardsController.class_eval do

  def create
    @new_messageboard = Thredded::Messageboard.new(messageboard_params)
    authorize_creating @new_messageboard
    if Thredded::CreateMessageboard.new(@new_messageboard, thredded_current_user).run
      redirect_to Thredded::UrlsHelper::show_messageboard_group_path(@collection.messageboard_group)
    else
      render :new
    end
  end
end

And suppress the search across messageboard groups within the topic controller In app/overrides/controllers/thredded/topics_controller_override.rb

Thredded::TopicsController.class_eval do

  def search
    @query = params[:q].to_s
    # add messageboard_group_id
    messageboard_ids = @collection.messageboard_group.messageboards.map{|mb| mb.id}

    page_scope = topics_scope
      .where(messageboard_id: messageboard_ids)
      .search_query(@query)
      .order_recently_posted_first
      .includes(:categories, :last_user, :user)
      .send(Kaminari.config.page_method_name, current_page)
    return redirect_to(last_page_params(page_scope)) if page_beyond_last?(page_scope)
    @topics = Thredded::TopicsPageView.new(thredded_current_user, page_scope)
  end

end

The full list of changes we made to our application to integrate Thredded and make it multi-tenant are at this pull request

Many thanks to the folks running Thredded.