Skip to content

Lesson 1 — The operational model

This lesson is the housekeeping lesson for Module 5. The dispatch deck has been a chassis with two spatial models so far — Depot and ServiceArea — plus three placeholder users (manager, dispatcher, field officer). For everything that follows in the tutorial, we need the operational model: the customers who request work, the jobs that get dispatched, and the relationships connecting users to depots and service areas.

We’ll do all of that in a single lesson — one migration, one model file pass, one seed task. By the end, your database holds 40 users, 9 depots, 340 service areas, ~2,500 customers, and 5,000 jobs. From the next lesson onward, we focus entirely on what we can do with this data — building role-aware UI, scoped queries, and the spatial joins that make the dispatcher’s view come alive.

The lesson is long because there’s a lot to set up, but it asks only for sequential, mechanical work. Read each section, make the change, move on. The teaching density picks up from Lesson 2.

A reference ER diagram for the data model lives in the appendix — open it in another tab if you want to keep the relationships visible while working through this lesson.

What we’re modelling

Five entities, four relationships:

  • User with a role (manager, dispatcher, or field officer)
  • Depot — a regional dispatch hub
  • ServiceArea — an ABS SA3 boundary
  • Customer — the person or business requesting work
  • Job — a piece of work to be done

Relationships:

  • A Depot has many dispatchers (Users with the dispatcher role) and oversees many ServiceAreas
  • A ServiceArea belongs to a Depot and houses field officers (Users with the field_officer role)
  • A Customer has many Jobs
  • A Job belongs to a Customer, a ServiceArea, and optionally to a User (the assigned field officer)

The User model picks up two new optional foreign keys — depot_id for dispatchers, service_area_id for field officers. Managers have neither.

The migration

Generate the migration:

1
bin/rails generate migration IntroduceOperationalModel

Replace the generated file’s contents with:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class IntroduceOperationalModel < ActiveRecord::Migration[8.0]
  def change
    # User → operational link
    add_reference :users, :depot,        foreign_key: true, null: true
    add_reference :users, :service_area, foreign_key: true, null: true

    # Service areas now belong to a depot (which oversees them)
    add_reference :service_areas, :depot, foreign_key: true, null: true

    # Customers
    create_table :customers do |t|
      t.string   :name,     null: false
      t.string   :phone,    null: false
      t.string   :email
      t.string   :address,  null: false
      t.string   :suburb,   null: false
      t.string   :postcode, null: false
      t.string   :state,    null: false
      t.st_point :location, srid: 4326, null: false

      t.timestamps
    end
    add_index :customers, :location, using: :gist
    add_index :customers, :suburb
    add_index :customers, :postcode

    # Jobs
    create_table :jobs do |t|
      t.string :reference,   null: false
      t.text   :description, null: false
      t.string :status,      null: false, default: "pending"
      t.string :priority,    null: false, default: "normal"

      t.references :customer,     foreign_key: true, null: false
      t.references :service_area, foreign_key: true, null: false
      t.references :assigned_to,  foreign_key: { to_table: :users }, null: true

      t.st_point :location, srid: 4326, null: false

      t.datetime :scheduled_for
      t.datetime :dispatched_at
      t.datetime :started_at
      t.datetime :completed_at

      t.timestamps
    end
    add_index :jobs, :reference,     unique: true
    add_index :jobs, :status
    add_index :jobs, :scheduled_for
    add_index :jobs, :location, using: :gist
  end
end

Run it:

1
bin/rails db:migrate

Walk through what’s happening, briefly:

The add_reference calls on users add nullable foreign keys. users.depot_id will be set for dispatchers; left null for managers and field officers. users.service_area_id will be set for field officers; null for the others. Both columns get FK constraints (foreign_key: true) so the database ensures referential integrity.

The add_reference on service_areas adds depot_id — each SA3 belongs to a depot whose dispatcher team is responsible for it. Nullable because some remote SA3s might not have a depot assigned (no realistic seed values do, but the column allows it).

The customers table carries name, phone, email (optional), and address fields. The location is a PostGIS point — the customer’s geocoded address. Indexed with GiST for spatial queries.

The jobs table has a status column (we’ll wire up the enum in the model), a foreign key to customer, service_area, and the optional assigned_to (a User — the field officer the job has been dispatched to). The assigned_to reference uses to_table: :users because the column name doesn’t match the table name. Like customers, the location is a GiST-indexed PostGIS point.

Updating the User model

Open app/models/user.rb. The chassis User model has the ROLES constant, validation, and predicates we set up in the chassis. Add the new associations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class User < ApplicationRecord
  ROLES = %w[manager dispatcher field_officer].freeze

  has_secure_password

  has_many :sessions, dependent: :destroy

  belongs_to :depot,        optional: true
  belongs_to :service_area, optional: true

  has_many :assigned_jobs,
           class_name: "Job",
           foreign_key: :assigned_to_id,
           dependent: :nullify

  validates :role, inclusion: { in: ROLES }
  validates :name, presence: true

  ROLES.each do |role|
    define_method("#{role}?") { self.role == role }
  end
end

Two new pieces:

belongs_to :depot, optional: true and belongs_to :service_area, optional: true — the optional flag is essential. Default Rails behaviour requires belongs_to associations to have a value; ours can be nil depending on the user’s role.

has_many :assigned_jobs — the field officer’s currently assigned jobs. Uses an explicit foreign_key: because the column is assigned_to_id, not user_id. The dependent: :nullify means if a user is deleted, their jobs aren’t deleted — they just lose their assignment.

The Depot model

Open app/models/depot.rb and add the relationships:

1
2
3
4
5
6
7
8
9
class Depot < ApplicationRecord
  has_many :service_areas
  has_many :dispatchers,
           -> { where(role: "dispatcher") },
           class_name: "User"

  validates :name, presence: true
  validates :code, presence: true, uniqueness: true
end

The dispatchers association is scoped to users with the dispatcher role. So depot.dispatchers returns only users whose role is “dispatcher” and whose depot_id is this depot’s id.

The ServiceArea model

Update app/models/service_area.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class ServiceArea < ApplicationRecord
  belongs_to :depot, optional: true

  has_many :field_officers,
           -> { where(role: "field_officer") },
           class_name: "User"

  has_many :jobs

  validates :name, presence: true
  validates :code, presence: true, uniqueness: true
end

Same scope-based pattern as Depot’s dispatchers — the field_officers association returns only users whose role is “field_officer” and whose service_area_id is this SA3’s id.

The Customer model

Create app/models/customer.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Customer < ApplicationRecord
  has_many :jobs

  validates :name,     presence: true
  validates :phone,    presence: true
  validates :address,  presence: true
  validates :suburb,   presence: true
  validates :postcode, presence: true
  validates :state,    presence: true
  validates :location, presence: true
end

Straightforward — most fields are required, a customer has many jobs.

The Job model

Create app/models/job.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Job < ApplicationRecord
  STATUSES   = %w[pending scheduled dispatched in_progress complete cancelled].freeze
  PRIORITIES = %w[low normal high urgent].freeze

  belongs_to :customer
  belongs_to :service_area
  belongs_to :assigned_to,
             class_name: "User",
             optional: true

  validates :reference,   presence: true, uniqueness: true
  validates :description, presence: true
  validates :status,      inclusion: { in: STATUSES }
  validates :priority,    inclusion: { in: PRIORITIES }
  validates :location,    presence: true

  STATUSES.each do |status|
    define_method("#{status}?") { self.status == status }
    scope status.to_sym, -> { where(status: status) }
  end
end

A few details worth noting.

Status and priority as plain string columns with validation. Rails 8 has good enum support, but for tutorial simplicity we’re using string columns with inclusion: validation — exactly the same pattern as the User role. The predicate methods (job.complete?, job.in_progress?) and named scopes (Job.pending, Job.dispatched) are added by the metaprogramming at the bottom.

assigned_to is the field officer. The naming reads naturally: job.assigned_to returns the User assigned to this job. The reverse — User’s assigned_jobs — is on the User model.

The seed task

This module’s seed needs a data file. Download module5_data.zip from the tutorial appendix and unzip it into db/data/:

bash unzip module5_data.zip -d db/data/

The chassis ships a rake task that consumes the data and populates everything else Module 5 needs:

bash bin/rails vera:seed_module_5

This takes about 30-60 seconds and reports its progress. It:

  • Adds 8 additional dispatchers (one per non-Sydney depot)
  • Adds 30 field officers distributed across SA3s
  • Assigns each ServiceArea to its depot
  • Imports 2,500 customers from the vendored data
  • Generates 5,000 jobs

When done:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Done. Final counts:
  Users:         41 (manager: 1, dispatchers: 9, field officers: 31)
  Depots:        9
  Service areas: 340 (assigned to depot: 336)
  Customers:     2500
  Jobs:          4366

  Job status distribution:
    cancelled     185
    complete      3884
    dispatched    16
    in_progress   25
    pending       49
    scheduled     207

The database is now ready for the rest of the tutorial.

The full task is shipped in the chassis. Briefly, what it does:

seed_dispatchers — creates 8 additional dispatchers (the chassis already had dispatcher@vera.test; we’re adding 8 more, one per non-Sydney depot). Each gets a name, email like dispatcher.brisbane@vera.test, and is linked to their depot.

seed_field_officers — creates 30 field officers with emails like field_officer.5@vera.test, distributed across service areas weighted by depot region size. Each links to a service area.

seed_service_area_depots — assigns each ServiceArea its depot, based on which state’s depot region it falls in. NSW SA3s are split between Sydney and Newcastle depots based on geographic position (Sydney depot covers metro Sydney and the coast; Newcastle covers the rest of NSW). QLD likewise (Brisbane for south-east, Cairns for north). Other states have one depot covering the lot.

seed_customers — reads db/data/customers.csv.gz and creates the customer records with their geocoded location.

seed_jobs — generates 5,000 jobs. Each picks a random customer (weighted so some have many jobs, most have few), uses the customer’s location, assigns to the service area containing that point, picks a status weighted to current state (mostly complete, some pending and scheduled), assigns to a field officer if status is dispatched-or-later, and sets realistic timestamps based on status.

Verifying the data

Open the Rails console and look around:

1
bin/rails console
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Counts
User.count                                          # => 40
Customer.count                                      # => 2500
Job.count                                           # => 5000

# Role distribution
User.group(:role).count
# => {"manager" => 1, "dispatcher" => 9, "field_officer" => 30}

# Job status distribution
Job.group(:status).count
# => {"pending" => 50, "scheduled" => 200, "dispatched" => 20,
#     "in_progress" => 30, "complete" => 4500, "cancelled" => 200}

# Get a dispatcher and walk their associations
dispatcher = User.find_by(email_address: "dispatcher@vera.test")
dispatcher.depot.name                               # => "Sydney Central"
dispatcher.depot.service_areas.count                # => approx. 70 (Sydney metro + coastal NSW SA3s)
dispatcher.depot.service_areas.flat_map(&:jobs).count  # => the jobs in their region

# A field officer's chain to their dispatcher
fo = User.find_by(email_address: "field_officer.1@vera.test")
fo.service_area.name                                # => their SA3
fo.service_area.depot.dispatchers.first.name        # => their dispatcher

Where this leaves us

The dispatch deck now has its full data model. From here on, every lesson focuses on what we do with this data — never again on schema setup or seeding. The operational complexity is in place; the rest of Module 5 (and Modules 6 onward) builds behaviour on top.

Lesson 2 starts with the immediate visible change: making the sidebar navigation role-aware. Each role sees the items they care about, not a uniform menu. The chassis’s existing Components::Sidebar gets its first real conditional logic.