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
dispatcherrole) and oversees many ServiceAreas - A ServiceArea belongs to a Depot and houses field
officers (Users with the
field_officerrole) - 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:
|
|
Replace the generated file’s contents with:
|
|
Run it:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
Straightforward — most fields are required, a customer has many jobs.
The Job model
Create app/models/job.rb:
|
|
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:
|
|
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:
|
|
|
|
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.