Building Mortimer, day 6

We left the project last night in a state of alarm, full of questions - was I going down with a bug, would the world rise to a new day, how about the (latest) would-have-been assassination attempts on the presidential kandidat DJT, more... Time to reflect, and to look forward.

Day 6

With the essential use case checked off I can focus on stuff like

as an admin user I will manage users

This use case leaves amble space for interpretations but a list of current users and some way to invite new ones, and to change the role of a user - I guess that will cover most of the bases.

rails g controller Users

Devise affords us the luxury of signing users up/in/out/more but not any management - on purpose. So I added a controller to do just that, and added a resource in the config/routes.rb for handling the 7R4 ( 7 Resource Actions ).

Special sauce

15+ years ago, a guy named Ian White, teached a ways to handle resources that really resonated in me hence the following "deroute" – programmer use case if you will:

as a programmer I'd like to not have to repeat myself too much - please DRY the source

Adding a controller in between ApplicationController and the actual resource controller eg UsersController will allow you to abstract away all the 7R4. Mine is called MortimerController and eg the index method will add a few important variables like @resources. I like to squeeze in one more controller, BaseController, where I point to the layout and include things like Authentication and pagination.

Authentication is a module that basically calls Devise authenticate_user! It does one more thing: opens the heretic Current object and assigns the current_user to Current.user.

Pagination is a somewhat different kind of animal! I'll comment this out for now and move on – but rest assure: we will get back to this one!

class MortimerController < BaseController
  #
  # defined in the resourceable concern
  # mentioned here for explicitness
  #
  before_action :set_resource, only: %i[ new show edit update destroy ]
  before_action :set_filter, only: %i[ index destroy ]
  before_action :set_resources, only: %i[ index destroy ]
  before_action :set_resources_stream

  include Resourceable
  include DefaultActions
  include TimezoneLocale
end

Another "time saver" is the addition of a folder named views/application where I put common view elements like index.html.erb

SSL

I shamelessly copied the good work by Jason Fleetwood-Boldt and so there is nothing to say but - read here:

Using Rails with SSL on Localhost
Although most simple apps will operate just fine running on http://localhost:3000 (the default for Rails), it is often advantageous to run…

TimezoneLocale

The trick is to insert a Rails special once again - it is called around_action like this:

module TimezoneLocale
  extend ActiveSupport::Concern

  #
  # about handling date and timezone
  # https://nandovieira.com/working-with-dates-on-ruby-on-rails
  #
  included do
    around_action :switch_locale
    around_action :user_time_zone if Current.user || Current.tenant
  end

  private

    #
    # switch locale to user preferred language - or by params[:locale]
    # https://phrase.com/blog/posts/rails-i18n-best-practices/
    #
    def switch_locale(&action)
      locale = extract_locale_from_tld || I18n.default_locale
      locale = params.permit![:lang] || locale
      locale = params.permit![:locale] || locale
      # locale = current_user.preferred_locale if current_user rescue locale
      parsed_locale = get_locale_from_user_or_account || locale
      I18n.with_locale(parsed_locale, &action)
    end

    # Get locale from top-level domain (put)
    # 
    #   127.0.0.1 application.com
    #   127.0.0.1 application.it
    #   127.0.0.1 application.pl
    #
    #   in your /etc/hosts file to try this out locally
    #
    def extract_locale_from_tld
      parsed_locale = request.host.split(".").last
      I18n.available_locales.map(&:to_s).include?(parsed_locale) ?
        parsed_locale : 
        nil
    end

    def get_locale_from_user_or_account
      Current.user&.locale ||
      Current.tenant&.locale
    end

    #
    # make sure we use the timezone (of the current_user)
    #
    def user_time_zone(&block)
      timezone = Current.user.time_zone || 
        Current.tenant&.time_zone rescue nil
      timezone.blank? ?
        Time.use_zone("Europe/Copenhagen", &block) :
        Time.use_zone(timezone, &block)
    end
end

TenantHandling

All queries will need either a default_scope or some other way of getting scoped to the tenant in question. I went with this:

module TenantHandling
  extend ActiveSupport::Concern

  included do
    belongs_to :tenant

    scope :by_tenant, ->(acc = nil) {
      return where(tenant: acc) unless acc.nil?
      if Current.user.present?
        case Current.user.role
        when "superadmin"
          Current.user.global_queries? ? 
            all : 
            where(tenant: Current.tenant)
        when "admin", "user"
          where(tenant: Current.tenant)
        end
      else
        if Current.tenant.present?
          where(tenant: Current.tenant)
        else
          all
        end
      end
    }
  end
end

UserRegistrationService

In other applications I've enjoyed moving parts of the controller into a service object to "clean" up the controller – it's not necessarily a good thing to "hide" code but if you might be calling this task from various places, it could be valuable to not duplicate code.

class UserRegistrationService
  def self.call(user)
    if user.persisted?
      user.update locale: "da", time_zone: "Europe/Copenhagen"
      UserMailer.with(user: user).welcome.deliver_later
      Team.create tenant: user.tenant, 
        name: I18n.t("teams.default_team"), 
        locale: user.locale, 
        time_zone: user.time_zone
    end
  end
end

Compressing Migrations

I could probably compress it into a single migration but now it's in 5-6 – but what I noted of interest is that in the process I had to redo the work numerous times because I was not 100% in the know of how the moving pieces fit together.

You will do rails db:drop followed by rm db/schema.rb and the move the migration file contents into fewer files. Follow that with rails db:create and then rails db:prepare will pull in any db/seeds.rb too.

Seeds

Seeding is the process of preparing the app when initially getting installed. I didn't go overboard in prepping - but just a few records would make it easier to get started no matter where the app would get deployed:

tenant = Tenant.find_or_create_by!
  name: "Mortimer", 
  locale: "en", 
  time_zone: "UTC", 
  account_color: "bg-blue-200", 
  tax_number: "N/A"
  
team = Team.find_or_create_by!
  tenant: tenant, 
  name: "Mortimer", 
  email: "info@mortimer.pro", 
  team_color: "bg-blue-200", 
  locale: "en", 
  time_zone: "UTC", 
  active: true
  
user = User.new(
  email: 'john@doe.com',
  tenant: tenant,
  team: team,
  role: 0,
  password: 'john!doe',
  password_confirmation: 'john!doe'
)
user.skip_confirmation!
user.save!

Phew 😅 that was indeed quite a lot of work (most of it was mostly copy/pasta though as I was 'transposing' from vol 3) and I didn't get one inch closer to managing users, unfortunately. Well, I spent some time setting up the scaffold for easier adding the admin part of the application, and that'll have to count for something too; and HTTPS, yay 😆 – tagging it as 0.5.0_ssl

Walther H Diechmann

Walther H Diechmann

Got on the train just about when the IBM PC 5150 got introduced and never really got of again - switched to Apple Mac in about 2006 though, and never looked back. It's been such a ride!
Silkeborg, Denmark