diff --git a/Gemfile b/Gemfile
index 1db8a8b..8b4d05b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -85,6 +85,9 @@ group :development do
# Docker-based deployment [https://kamal-deploy.org]
gem "kamal", require: false
+
+ # Process manager for bin/dev (web + Tailwind watcher) [https://github.com/ddollar/foreman]
+ gem "foreman", require: false
end
group :test do
diff --git a/Gemfile.lock b/Gemfile.lock
index b8b5aed..7a89b4c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -152,6 +152,8 @@ GEM
ffi (1.17.4-x86_64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
+ foreman (0.90.0)
+ thor (~> 1.4)
fugit (1.12.2)
et-orbi (~> 1.4)
raabro (~> 1.4)
@@ -482,6 +484,7 @@ DEPENDENCIES
debug
factory_bot_rails
faker
+ foreman
groupdate
image_processing (~> 1.2)
importmap-rails
@@ -568,6 +571,7 @@ CHECKSUMS
ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
+ foreman (0.90.0) sha256=ff675e2d47b607ac58714a6d4ac3e1ee8f06f41d8db084531c31961e2c3f117c
fugit (1.12.2) sha256=643f2bf28db263bd400cbf8e0dd8b76b2c9b94bdb130e12d2394de04d9c20e5e
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
groupdate (6.8.0) sha256=16f90ea18ca981c38e41e592416480b28353aa5e5cc3e7cb13a68b8fb5d40510
diff --git a/README.md b/README.md
index b48a5be..f220857 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,7 @@ that keeps it from happening again.
🚧 In active development. Roadmap:
- [x] Phase 0 — Foundations: tooling, test suite, CI, security scanning
-- [ ] Phase 1 — Fleet core: accounts, auth, vehicles (VIN decoding), drivers, assignments
+- [x] Phase 1 — Fleet core: accounts, auth, vehicles (VIN decoding), drivers, assignments
- [ ] Phase 2 — Maintenance: service records, schedules, renewals, alert engine
- [ ] Phase 3 — Integrations: Twilio SMS, SendGrid email, Stripe subscriptions
- [ ] Phase 4 — Reports: cost dashboards, fuel tracking, demo seeds
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
new file mode 100644
index 0000000..4264c74
--- /dev/null
+++ b/app/channels/application_cable/connection.rb
@@ -0,0 +1,16 @@
+module ApplicationCable
+ class Connection < ActionCable::Connection::Base
+ identified_by :current_user
+
+ def connect
+ set_current_user || reject_unauthorized_connection
+ end
+
+ private
+ def set_current_user
+ if session = Session.find_by(id: cookies.signed[:session_id])
+ self.current_user = session.user
+ end
+ end
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index c353756..bf1d7d5 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,7 +1,29 @@
class ApplicationController < ActionController::Base
+ include Authentication
+ include Pundit::Authorization
+ include Pagy::Method
+
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes
+
+ rescue_from Pundit::NotAuthorizedError, with: :forbidden
+
+ helper_method :current_account
+
+ private
+
+ def current_account
+ Current.account
+ end
+
+ def pundit_user
+ Current.user
+ end
+
+ def forbidden
+ redirect_back fallback_location: root_path, alert: "You are not allowed to do that."
+ end
end
diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb
new file mode 100644
index 0000000..ee787ea
--- /dev/null
+++ b/app/controllers/assignments_controller.rb
@@ -0,0 +1,54 @@
+class AssignmentsController < ApplicationController
+ before_action :set_assignment, only: %i[edit update destroy]
+
+ def new
+ @assignment = current_account.assignments.new(
+ vehicle_id: params[:vehicle_id], driver_id: params[:driver_id], started_on: Date.current
+ )
+ authorize @assignment
+ end
+
+ def create
+ @assignment = current_account.assignments.new(assignment_params)
+ authorize @assignment
+
+ if @assignment.save
+ redirect_to @assignment.vehicle, notice: "Driver assigned."
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if @assignment.update(assignment_params)
+ redirect_to @assignment.vehicle, notice: "Assignment updated."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ vehicle = @assignment.vehicle
+ @assignment.destroy!
+ redirect_to vehicle, notice: "Assignment removed."
+ end
+
+ private
+
+ def set_assignment
+ @assignment = current_account.assignments.find(params[:id])
+ authorize @assignment
+ end
+
+ def assignment_params
+ permitted = params.expect(assignment: [ :vehicle_id, :driver_id, :started_on, :ended_on ])
+ # Resolve associations through the tenant so foreign ids can't escape the account.
+ permitted.merge(
+ vehicle: current_account.vehicles.find(permitted[:vehicle_id]),
+ driver: current_account.drivers.find(permitted[:driver_id])
+ ).except(:vehicle_id, :driver_id)
+ end
+end
diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
new file mode 100644
index 0000000..3538f48
--- /dev/null
+++ b/app/controllers/concerns/authentication.rb
@@ -0,0 +1,52 @@
+module Authentication
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :require_authentication
+ helper_method :authenticated?
+ end
+
+ class_methods do
+ def allow_unauthenticated_access(**options)
+ skip_before_action :require_authentication, **options
+ end
+ end
+
+ private
+ def authenticated?
+ resume_session
+ end
+
+ def require_authentication
+ resume_session || request_authentication
+ end
+
+ def resume_session
+ Current.session ||= find_session_by_cookie
+ end
+
+ def find_session_by_cookie
+ Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
+ end
+
+ def request_authentication
+ session[:return_to_after_authenticating] = request.url
+ redirect_to new_session_path
+ end
+
+ def after_authentication_url
+ session.delete(:return_to_after_authenticating) || root_url
+ end
+
+ def start_new_session_for(user)
+ user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
+ Current.session = session
+ cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
+ end
+ end
+
+ def terminate_session
+ Current.session.destroy
+ cookies.delete(:session_id)
+ end
+end
diff --git a/app/controllers/drivers_controller.rb b/app/controllers/drivers_controller.rb
new file mode 100644
index 0000000..8d3e393
--- /dev/null
+++ b/app/controllers/drivers_controller.rb
@@ -0,0 +1,57 @@
+class DriversController < ApplicationController
+ before_action :set_driver, only: %i[show edit update destroy]
+
+ def index
+ authorize Driver
+ drivers = current_account.drivers.search(params[:q])
+ drivers = drivers.where(status: params[:status]) if params[:status].present?
+ @pagy, @drivers = pagy(drivers.order(:name))
+ end
+
+ def show
+ @assignments = @driver.assignments.includes(:vehicle).order(started_on: :desc)
+ end
+
+ def new
+ @driver = current_account.drivers.new
+ authorize @driver
+ end
+
+ def create
+ @driver = current_account.drivers.new(driver_params)
+ authorize @driver
+
+ if @driver.save
+ redirect_to @driver, notice: "Driver added."
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if @driver.update(driver_params)
+ redirect_to @driver, notice: "Driver updated."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @driver.destroy!
+ redirect_to drivers_path, notice: "Driver removed."
+ end
+
+ private
+
+ def set_driver
+ @driver = current_account.drivers.find(params[:id])
+ authorize @driver
+ end
+
+ def driver_params
+ params.expect(driver: [ :name, :email, :phone, :license_number, :license_expires_on, :status ])
+ end
+end
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
new file mode 100644
index 0000000..f95ec78
--- /dev/null
+++ b/app/controllers/passwords_controller.rb
@@ -0,0 +1,35 @@
+class PasswordsController < ApplicationController
+ allow_unauthenticated_access
+ before_action :set_user_by_token, only: %i[ edit update ]
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
+
+ def new
+ end
+
+ def create
+ if user = User.find_by(email_address: params[:email_address])
+ PasswordsMailer.reset(user).deliver_later
+ end
+
+ redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
+ end
+
+ def edit
+ end
+
+ def update
+ if @user.update(params.permit(:password, :password_confirmation))
+ @user.sessions.destroy_all
+ redirect_to new_session_path, notice: "Password has been reset."
+ else
+ redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
+ end
+ end
+
+ private
+ def set_user_by_token
+ @user = User.find_by_password_reset_token!(params[:token])
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
+ redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
+ end
+end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
new file mode 100644
index 0000000..f20313c
--- /dev/null
+++ b/app/controllers/registrations_controller.rb
@@ -0,0 +1,25 @@
+class RegistrationsController < ApplicationController
+ allow_unauthenticated_access
+ rate_limit to: 5, within: 1.minute, only: :create
+
+ def new
+ @registration = Registration.new
+ end
+
+ def create
+ @registration = Registration.new(registration_params)
+
+ if @registration.save
+ start_new_session_for @registration.user
+ redirect_to root_path, notice: "Welcome to FleetPilot, #{@registration.account.name}!"
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def registration_params
+ params.expect(registration: [ :account_name, :email_address, :password, :password_confirmation ])
+ end
+end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
new file mode 100644
index 0000000..cf7fccd
--- /dev/null
+++ b/app/controllers/sessions_controller.rb
@@ -0,0 +1,21 @@
+class SessionsController < ApplicationController
+ allow_unauthenticated_access only: %i[ new create ]
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
+
+ def new
+ end
+
+ def create
+ if user = User.authenticate_by(params.permit(:email_address, :password))
+ start_new_session_for user
+ redirect_to after_authentication_url
+ else
+ redirect_to new_session_path, alert: "Try another email address or password."
+ end
+ end
+
+ def destroy
+ terminate_session
+ redirect_to new_session_path, status: :see_other
+ end
+end
diff --git a/app/controllers/vehicles_controller.rb b/app/controllers/vehicles_controller.rb
new file mode 100644
index 0000000..72502d1
--- /dev/null
+++ b/app/controllers/vehicles_controller.rb
@@ -0,0 +1,59 @@
+class VehiclesController < ApplicationController
+ before_action :set_vehicle, only: %i[show edit update destroy]
+
+ def index
+ authorize Vehicle
+ vehicles = current_account.vehicles.search(params[:q])
+ vehicles = vehicles.where(status: params[:status]) if params[:status].present?
+ @pagy, @vehicles = pagy(vehicles.order(created_at: :desc))
+ end
+
+ def show
+ @assignments = @vehicle.assignments.includes(:driver).order(started_on: :desc)
+ end
+
+ def new
+ @vehicle = current_account.vehicles.new
+ authorize @vehicle
+ end
+
+ def create
+ @vehicle = current_account.vehicles.new(vehicle_params)
+ authorize @vehicle
+
+ if @vehicle.save
+ VinDecodeJob.perform_later(@vehicle)
+ redirect_to @vehicle, notice: "Vehicle added. Make, model and year will be filled in from the VIN shortly."
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if @vehicle.update(vehicle_params)
+ redirect_to @vehicle, notice: "Vehicle updated."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @vehicle.destroy!
+ redirect_to vehicles_path, notice: "Vehicle removed."
+ end
+
+ private
+
+ def set_vehicle
+ @vehicle = current_account.vehicles.find(params[:id])
+ authorize @vehicle
+ end
+
+ def vehicle_params
+ params.expect(vehicle: [ :vin, :make, :model, :year, :license_plate, :status,
+ :odometer, :fuel_type, :purchased_on, :purchase_price_cents ])
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index de6be79..7797d64 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,2 +1,12 @@
module ApplicationHelper
+ def nav_link_class(path)
+ base = "rounded-md px-3 py-1.5 text-sm hover:bg-slate-700"
+ current_page?(path) ? "#{base} bg-slate-700 font-medium" : base
+ end
+
+ def money(cents)
+ return "—" if cents.blank?
+
+ number_to_currency(cents / 100.0)
+ end
end
diff --git a/app/javascript/controllers/autosubmit_controller.js b/app/javascript/controllers/autosubmit_controller.js
new file mode 100644
index 0000000..9f4842c
--- /dev/null
+++ b/app/javascript/controllers/autosubmit_controller.js
@@ -0,0 +1,13 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Submits the surrounding form when inputs change, debounced for text fields.
+export default class extends Controller {
+ submit() {
+ clearTimeout(this.timeout)
+ this.timeout = setTimeout(() => this.element.requestSubmit(), 300)
+ }
+
+ disconnect() {
+ clearTimeout(this.timeout)
+ }
+}
diff --git a/app/jobs/vin_decode_job.rb b/app/jobs/vin_decode_job.rb
new file mode 100644
index 0000000..5bd9e86
--- /dev/null
+++ b/app/jobs/vin_decode_job.rb
@@ -0,0 +1,19 @@
+# Fills in make/model/year from the NHTSA vPIC API after a vehicle is created.
+# Never overwrites data the user typed in.
+class VinDecodeJob < ApplicationJob
+ queue_as :default
+
+ retry_on Net::OpenTimeout, Net::ReadTimeout, wait: :polynomially_longer, attempts: 3
+
+ def perform(vehicle)
+ result = VinDecoder.decode(vehicle.vin)
+ return if result.nil?
+
+ vehicle.with_lock do
+ vehicle.make = result.make if vehicle.make.blank?
+ vehicle.model = result.model if vehicle.model.blank?
+ vehicle.year = result.year if vehicle.year.blank?
+ vehicle.save!
+ end
+ end
+end
diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb
new file mode 100644
index 0000000..4f0ac7f
--- /dev/null
+++ b/app/mailers/passwords_mailer.rb
@@ -0,0 +1,6 @@
+class PasswordsMailer < ApplicationMailer
+ def reset(user)
+ @user = user
+ mail subject: "Reset your password", to: user.email_address
+ end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
new file mode 100644
index 0000000..8092744
--- /dev/null
+++ b/app/models/account.rb
@@ -0,0 +1,8 @@
+class Account < ApplicationRecord
+ has_many :users, dependent: :destroy
+ has_many :vehicles, dependent: :destroy
+ has_many :drivers, dependent: :destroy
+ has_many :assignments, dependent: :destroy
+
+ validates :name, presence: true
+end
diff --git a/app/models/assignment.rb b/app/models/assignment.rb
new file mode 100644
index 0000000..a3fc9b6
--- /dev/null
+++ b/app/models/assignment.rb
@@ -0,0 +1,48 @@
+class Assignment < ApplicationRecord
+ include AccountScoped
+
+ belongs_to :vehicle
+ belongs_to :driver
+
+ scope :current, -> { where(ended_on: nil).or(where(ended_on: Date.current..)) }
+ scope :overlapping, ->(starts, ends) {
+ if ends.nil?
+ where("ended_on IS NULL OR ended_on >= ?", starts)
+ else
+ where("started_on <= ?", ends).where("ended_on IS NULL OR ended_on >= ?", starts)
+ end
+ }
+
+ validates :started_on, presence: true
+ validates :ended_on, comparison: { greater_than_or_equal_to: :started_on }, allow_nil: true
+ validate :vehicle_and_driver_belong_to_account
+ validate :no_overlap_for_vehicle
+ validate :no_overlap_for_driver
+
+ def current?
+ ended_on.nil? || !ended_on.past?
+ end
+
+ private
+
+ def vehicle_and_driver_belong_to_account
+ errors.add(:vehicle, "belongs to another account") if vehicle && vehicle.account_id != account_id
+ errors.add(:driver, "belongs to another account") if driver && driver.account_id != account_id
+ end
+
+ def no_overlap_for_vehicle
+ return if vehicle.nil? || started_on.nil?
+
+ if vehicle.assignments.where.not(id: id).overlapping(started_on, ended_on).exists?
+ errors.add(:vehicle, "is already assigned during this period")
+ end
+ end
+
+ def no_overlap_for_driver
+ return if driver.nil? || started_on.nil?
+
+ if driver.assignments.where.not(id: id).overlapping(started_on, ended_on).exists?
+ errors.add(:driver, "is already assigned during this period")
+ end
+ end
+end
diff --git a/app/models/concerns/account_scoped.rb b/app/models/concerns/account_scoped.rb
new file mode 100644
index 0000000..a5844db
--- /dev/null
+++ b/app/models/concerns/account_scoped.rb
@@ -0,0 +1,9 @@
+# Tenant isolation: every domain record belongs to an Account and must only be
+# reached through an account association (e.g. Current.account.vehicles).
+module AccountScoped
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :account
+ end
+end
diff --git a/app/models/current.rb b/app/models/current.rb
new file mode 100644
index 0000000..c4dbd5a
--- /dev/null
+++ b/app/models/current.rb
@@ -0,0 +1,5 @@
+class Current < ActiveSupport::CurrentAttributes
+ attribute :session
+ delegate :user, to: :session, allow_nil: true
+ delegate :account, to: :user, allow_nil: true
+end
diff --git a/app/models/driver.rb b/app/models/driver.rb
new file mode 100644
index 0000000..4733abb
--- /dev/null
+++ b/app/models/driver.rb
@@ -0,0 +1,32 @@
+class Driver < ApplicationRecord
+ include AccountScoped
+
+ has_many :assignments, dependent: :destroy
+ has_many :vehicles, through: :assignments
+
+ enum :status, { active: 0, inactive: 1 }, default: :active
+
+ normalizes :email, with: ->(e) { e.strip.downcase }
+
+ validates :name, presence: true
+ validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
+
+ scope :search, ->(query) {
+ if query.present?
+ term = "%#{sanitize_sql_like(query.strip)}%"
+ where("name LIKE :t OR email LIKE :t OR license_number LIKE :t", t: term)
+ end
+ }
+
+ def license_expired?
+ license_expires_on.present? && license_expires_on.past?
+ end
+
+ def current_assignment
+ assignments.current.first
+ end
+
+ def current_vehicle
+ current_assignment&.vehicle
+ end
+end
diff --git a/app/models/registration.rb b/app/models/registration.rb
new file mode 100644
index 0000000..0225ed5
--- /dev/null
+++ b/app/models/registration.rb
@@ -0,0 +1,29 @@
+# Form object for the signup flow: creates the Account and its first admin
+# user in one transaction.
+class Registration
+ include ActiveModel::Model
+
+ attr_accessor :account_name, :email_address, :password, :password_confirmation
+ attr_reader :account, :user
+
+ validates :account_name, presence: true
+ validates :email_address, presence: true
+
+ def save
+ return false unless valid?
+
+ ActiveRecord::Base.transaction do
+ @account = Account.create!(name: account_name)
+ @user = @account.users.create!(
+ email_address: email_address,
+ password: password,
+ password_confirmation: password_confirmation,
+ role: :admin
+ )
+ end
+ true
+ rescue ActiveRecord::RecordInvalid => e
+ e.record.errors.each { |error| errors.add(error.attribute, error.message) }
+ false
+ end
+end
diff --git a/app/models/session.rb b/app/models/session.rb
new file mode 100644
index 0000000..cf376fb
--- /dev/null
+++ b/app/models/session.rb
@@ -0,0 +1,3 @@
+class Session < ApplicationRecord
+ belongs_to :user
+end
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..76afd2b
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,13 @@
+class User < ApplicationRecord
+ include AccountScoped
+
+ has_secure_password
+ has_many :sessions, dependent: :destroy
+
+ enum :role, { viewer: 0, manager: 1, admin: 2 }, default: :viewer
+
+ normalizes :email_address, with: ->(e) { e.strip.downcase }
+
+ validates :email_address, presence: true, uniqueness: true,
+ format: { with: URI::MailTo::EMAIL_REGEXP }
+end
diff --git a/app/models/vehicle.rb b/app/models/vehicle.rb
new file mode 100644
index 0000000..9d715cf
--- /dev/null
+++ b/app/models/vehicle.rb
@@ -0,0 +1,41 @@
+class Vehicle < ApplicationRecord
+ VIN_LENGTH = 17
+
+ include AccountScoped
+
+ has_many :assignments, dependent: :destroy
+ has_many :drivers, through: :assignments
+
+ enum :status, { active: 0, in_shop: 1, retired: 2 }, default: :active
+ enum :fuel_type, { gasoline: 0, diesel: 1, hybrid: 2, electric: 3, other: 4 }, prefix: true
+
+ normalizes :vin, with: ->(vin) { vin.strip.upcase }
+ normalizes :license_plate, with: ->(plate) { plate.strip.upcase }
+
+ validates :vin, presence: true,
+ length: { is: VIN_LENGTH },
+ format: { with: /\A[A-HJ-NPR-Z0-9]+\z/, message: "contains invalid characters (I, O and Q are not used in VINs)" },
+ uniqueness: { scope: :account_id }
+ validates :year, numericality: { only_integer: true, greater_than: 1900, less_than_or_equal_to: -> { Date.current.year + 1 } }, allow_nil: true
+ validates :odometer, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :purchase_price_cents, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
+
+ scope :search, ->(query) {
+ if query.present?
+ term = "%#{sanitize_sql_like(query.strip)}%"
+ where("vin LIKE :t OR make LIKE :t OR model LIKE :t OR license_plate LIKE :t", t: term)
+ end
+ }
+
+ def current_assignment
+ assignments.current.first
+ end
+
+ def current_driver
+ current_assignment&.driver
+ end
+
+ def display_name
+ [ year, make, model ].compact.join(" ").presence || vin
+ end
+end
diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb
new file mode 100644
index 0000000..fa3e8a0
--- /dev/null
+++ b/app/policies/application_policy.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+# Role model: viewers can read everything in their account, managers can also
+# manage fleet resources, admins can additionally manage account settings and
+# users. Tenant isolation itself happens in controllers (Current.account
+# associations), not here.
+class ApplicationPolicy
+ attr_reader :user, :record
+
+ def initialize(user, record)
+ @user = user
+ @record = record
+ end
+
+ def index?
+ true
+ end
+
+ def show?
+ true
+ end
+
+ def create?
+ manager_or_admin?
+ end
+
+ def new?
+ create?
+ end
+
+ def update?
+ manager_or_admin?
+ end
+
+ def edit?
+ update?
+ end
+
+ def destroy?
+ manager_or_admin?
+ end
+
+ private
+
+ def manager_or_admin?
+ user.manager? || user.admin?
+ end
+
+ class Scope
+ def initialize(user, scope)
+ @user = user
+ @scope = scope
+ end
+
+ def resolve
+ scope.all
+ end
+
+ private
+
+ attr_reader :user, :scope
+ end
+end
diff --git a/app/policies/assignment_policy.rb b/app/policies/assignment_policy.rb
new file mode 100644
index 0000000..62a031a
--- /dev/null
+++ b/app/policies/assignment_policy.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class AssignmentPolicy < ApplicationPolicy
+end
diff --git a/app/policies/driver_policy.rb b/app/policies/driver_policy.rb
new file mode 100644
index 0000000..6eee044
--- /dev/null
+++ b/app/policies/driver_policy.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class DriverPolicy < ApplicationPolicy
+end
diff --git a/app/policies/vehicle_policy.rb b/app/policies/vehicle_policy.rb
new file mode 100644
index 0000000..9d9f9e8
--- /dev/null
+++ b/app/policies/vehicle_policy.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class VehiclePolicy < ApplicationPolicy
+end
diff --git a/app/services/vin_decoder.rb b/app/services/vin_decoder.rb
new file mode 100644
index 0000000..db705bb
--- /dev/null
+++ b/app/services/vin_decoder.rb
@@ -0,0 +1,48 @@
+# Decodes a VIN against the free NHTSA vPIC API and returns the vehicle's
+# make, model and year. Returns nil when the VIN cannot be decoded.
+#
+# API docs: https://vpic.nhtsa.dot.gov/api/
+class VinDecoder
+ BASE_URL = "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues".freeze
+ OPEN_TIMEOUT = 5
+ READ_TIMEOUT = 10
+
+ Result = Data.define(:make, :model, :year)
+
+ def self.decode(vin)
+ new(vin).decode
+ end
+
+ def initialize(vin)
+ @vin = vin
+ end
+
+ def decode
+ row = fetch
+ return nil if row.nil? || row["Make"].blank?
+
+ Result.new(
+ make: row["Make"].titleize,
+ model: row["Model"].presence,
+ year: row["ModelYear"].presence&.to_i
+ )
+ end
+
+ private
+
+ attr_reader :vin
+
+ def fetch
+ uri = URI("#{BASE_URL}/#{URI.encode_uri_component(vin)}?format=json")
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
+ open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
+ http.get(uri.request_uri)
+ end
+ return nil unless response.is_a?(Net::HTTPSuccess)
+
+ JSON.parse(response.body).dig("Results", 0)
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, JSON::ParserError => e
+ Rails.logger.warn("VIN decode failed for #{vin}: #{e.class} #{e.message}")
+ nil
+ end
+end
diff --git a/app/views/assignments/_form.html.erb b/app/views/assignments/_form.html.erb
new file mode 100644
index 0000000..9856c5a
--- /dev/null
+++ b/app/views/assignments/_form.html.erb
@@ -0,0 +1,32 @@
+<%= form_with model: assignment, class: "max-w-xl space-y-4" do |form| %>
+ <%= render "shared/form_errors", record: assignment %>
+
+
+
+ <%= form.label :vehicle_id, "Vehicle", class: "block text-sm font-medium" %>
+ <%= form.collection_select :vehicle_id, current_account.vehicles.order(:make, :model), :id, :display_name,
+ { include_blank: "Select a vehicle" }, { required: true, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" } %>
+
+
+ <%= form.label :driver_id, "Driver", class: "block text-sm font-medium" %>
+ <%= form.collection_select :driver_id, current_account.drivers.active.order(:name), :id, :name,
+ { include_blank: "Select a driver" }, { required: true, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" } %>
+
+
+
+
+
+ <%= form.label :started_on, "From", class: "block text-sm font-medium" %>
+ <%= form.date_field :started_on, required: true, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :ended_on, "To (blank while current)", class: "block text-sm font-medium" %>
+ <%= form.date_field :ended_on, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
+
+ <%= form.submit class: "rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 cursor-pointer" %>
+ <%= link_to "Cancel", :back, class: "rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100" %>
+
+<% end %>
diff --git a/app/views/assignments/edit.html.erb b/app/views/assignments/edit.html.erb
new file mode 100644
index 0000000..6ae6534
--- /dev/null
+++ b/app/views/assignments/edit.html.erb
@@ -0,0 +1,11 @@
+<% content_for :title, "Edit assignment — FleetPilot" %>
+
+
+
Edit assignment
+ <% if policy(@assignment).destroy? %>
+ <%= button_to "Remove assignment", @assignment, method: :delete,
+ data: { turbo_confirm: "Remove this assignment?" },
+ class: "rounded-md border border-red-200 px-4 py-2 text-sm text-red-700 hover:bg-red-50 cursor-pointer" %>
+ <% end %>
+
+<%= render "form", assignment: @assignment %>
diff --git a/app/views/assignments/new.html.erb b/app/views/assignments/new.html.erb
new file mode 100644
index 0000000..57666c1
--- /dev/null
+++ b/app/views/assignments/new.html.erb
@@ -0,0 +1,4 @@
+<% content_for :title, "Assign driver — FleetPilot" %>
+
+Assign a driver to a vehicle
+<%= render "form", assignment: @assignment %>
diff --git a/app/views/drivers/_form.html.erb b/app/views/drivers/_form.html.erb
new file mode 100644
index 0000000..f13042a
--- /dev/null
+++ b/app/views/drivers/_form.html.erb
@@ -0,0 +1,41 @@
+<%= form_with model: driver, class: "max-w-xl space-y-4" do |form| %>
+ <%= render "shared/form_errors", record: driver %>
+
+
+ <%= form.label :name, class: "block text-sm font-medium" %>
+ <%= form.text_field :name, required: true, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
+
+ <%= form.label :email, class: "block text-sm font-medium" %>
+ <%= form.email_field :email, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :phone, class: "block text-sm font-medium" %>
+ <%= form.telephone_field :phone, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
+
+
+ <%= form.label :license_number, class: "block text-sm font-medium" %>
+ <%= form.text_field :license_number, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :license_expires_on, "License expires", class: "block text-sm font-medium" %>
+ <%= form.date_field :license_expires_on, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :status, class: "block text-sm font-medium" %>
+ <%= form.select :status, Driver.statuses.keys.map { |s| [ s.humanize, s ] },
+ {}, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
+
+ <%= form.submit class: "rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 cursor-pointer" %>
+ <%= link_to "Cancel", driver.persisted? ? driver_path(driver) : drivers_path,
+ class: "rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100" %>
+
+<% end %>
diff --git a/app/views/drivers/edit.html.erb b/app/views/drivers/edit.html.erb
new file mode 100644
index 0000000..8eec83e
--- /dev/null
+++ b/app/views/drivers/edit.html.erb
@@ -0,0 +1,4 @@
+<% content_for :title, "Edit driver — FleetPilot" %>
+
+Edit <%= @driver.name %>
+<%= render "form", driver: @driver %>
diff --git a/app/views/drivers/index.html.erb b/app/views/drivers/index.html.erb
new file mode 100644
index 0000000..54b3088
--- /dev/null
+++ b/app/views/drivers/index.html.erb
@@ -0,0 +1,63 @@
+<% content_for :title, "Drivers — FleetPilot" %>
+
+
+
Drivers
+ <% if policy(Driver).create? %>
+ <%= link_to "Add driver", new_driver_path,
+ class: "rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700" %>
+ <% end %>
+
+
+<%= form_with url: drivers_path, method: :get, data: { controller: "autosubmit" },
+ class: "mb-6 flex flex-wrap items-end gap-3" do |form| %>
+
+ <%= form.label :q, "Search", class: "block text-xs font-medium text-slate-500" %>
+ <%= form.search_field :q, value: params[:q], placeholder: "Name, email or license #",
+ data: { action: "input->autosubmit#submit" },
+ class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" %>
+
+
+ <%= form.label :status, class: "block text-xs font-medium text-slate-500" %>
+ <%= form.select :status,
+ options_for_select(Driver.statuses.keys.map { |s| [ s.humanize, s ] }, params[:status]),
+ { include_blank: "All statuses" },
+ data: { action: "change->autosubmit#submit" },
+ class: "mt-1 rounded-md border border-slate-300 px-3 py-2 text-sm" %>
+
+<% end %>
+
+<% if @drivers.any? %>
+
+
+
+
+ Name
+ Contact
+ License
+ License expires
+ Vehicle
+
+
+
+ <% @drivers.each do |driver| %>
+
+ <%= link_to driver.name, driver, class: "hover:underline" %>
+ <%= driver.email || driver.phone || "—" %>
+ <%= driver.license_number || "—" %>
+ ">
+ <%= driver.license_expires_on&.to_fs(:long) || "—" %>
+ <%= " (expired)" if driver.license_expired? %>
+
+ <%= driver.current_vehicle&.display_name || "—" %>
+
+ <% end %>
+
+
+
+ <%== @pagy.series_nav if @pagy.pages > 1 %>
+<% else %>
+
+
No drivers found
+
Add drivers so you can assign them to vehicles.
+
+<% end %>
diff --git a/app/views/drivers/new.html.erb b/app/views/drivers/new.html.erb
new file mode 100644
index 0000000..91f8489
--- /dev/null
+++ b/app/views/drivers/new.html.erb
@@ -0,0 +1,4 @@
+<% content_for :title, "Add driver — FleetPilot" %>
+
+Add driver
+<%= render "form", driver: @driver %>
diff --git a/app/views/drivers/show.html.erb b/app/views/drivers/show.html.erb
new file mode 100644
index 0000000..b12aca9
--- /dev/null
+++ b/app/views/drivers/show.html.erb
@@ -0,0 +1,69 @@
+<% content_for :title, "#{@driver.name} — FleetPilot" %>
+
+
+
+
<%= @driver.name %>
+
<%= @driver.email %>
+
+
+ <% if policy(@driver).update? %>
+ <%= link_to "Edit", edit_driver_path(@driver),
+ class: "rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100" %>
+ <% end %>
+ <% if policy(@driver).destroy? %>
+ <%= button_to "Delete", @driver, method: :delete,
+ data: { turbo_confirm: "Remove this driver and their history?" },
+ class: "rounded-md border border-red-200 px-4 py-2 text-sm text-red-700 hover:bg-red-50 cursor-pointer" %>
+ <% end %>
+
+
+
+
+
+
Details
+
+
Status <%= @driver.status.humanize %>
+
Phone <%= @driver.phone || "—" %>
+
License # <%= @driver.license_number || "—" %>
+
+
License expires
+ ">
+ <%= @driver.license_expires_on&.to_fs(:long) || "—" %><%= " (expired)" if @driver.license_expired? %>
+
+
+
+
+
+
+
+
Vehicle history
+ <% if policy(Assignment).create? %>
+ <%= link_to "Assign vehicle", new_assignment_path(driver_id: @driver.id),
+ class: "rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-700" %>
+ <% end %>
+
+
+ <% if @assignments.any? %>
+
+
+
+ Vehicle
+ From
+ To
+
+
+
+ <% @assignments.each do |assignment| %>
+
+ <%= link_to assignment.vehicle.display_name, assignment.vehicle, class: "hover:underline" %>
+ <%= assignment.started_on.to_fs(:long) %>
+ <%= assignment.ended_on&.to_fs(:long) || content_tag(:span, "Current", class: "rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800") %>
+
+ <% end %>
+
+
+ <% else %>
+
This driver has not been assigned to any vehicle yet.
+ <% end %>
+
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 9058959..72ac8eb 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -1,19 +1,16 @@
- <%= content_for(:title) || "Fleetpilot" %>
+ <%= content_for(:title) || "FleetPilot" %>
-
+
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= yield :head %>
- <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
- <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
-
@@ -23,8 +20,30 @@
<%= javascript_importmap_tags %>
-
-
+
+ <% if authenticated? %>
+
+
+ <%= link_to "FleetPilot", root_path, class: "text-lg font-bold tracking-tight" %>
+ <%= link_to "Vehicles", vehicles_path, class: nav_link_class(vehicles_path) %>
+ <%= link_to "Drivers", drivers_path, class: nav_link_class(drivers_path) %>
+
+ <%= current_account.name %>
+ <%= button_to "Sign out", session_path, method: :delete,
+ class: "rounded-md bg-slate-700 px-3 py-1.5 hover:bg-slate-600 cursor-pointer" %>
+
+
+
+ <% end %>
+
+
+ <% if notice %>
+ <%= notice %>
+ <% end %>
+ <% if alert %>
+ <%= alert %>
+ <% end %>
+
<%= yield %>
diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb
new file mode 100644
index 0000000..65798f8
--- /dev/null
+++ b/app/views/passwords/edit.html.erb
@@ -0,0 +1,21 @@
+
+ <% if alert = flash[:alert] %>
+
<%= alert %>
+ <% end %>
+
+
Update your password
+
+ <%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
+
+ <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
+
+
+
+ <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
+
+
+
+ <%= form.submit "Save", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
+
+ <% end %>
+
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb
new file mode 100644
index 0000000..8360e02
--- /dev/null
+++ b/app/views/passwords/new.html.erb
@@ -0,0 +1,17 @@
+
+ <% if alert = flash[:alert] %>
+
<%= alert %>
+ <% end %>
+
+
Forgot your password?
+
+ <%= form_with url: passwords_path, class: "contents" do |form| %>
+
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
+
+
+
+ <%= form.submit "Email reset instructions", class: "w-full sm:w-auto text-center rounded-lg px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
+
+ <% end %>
+
diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb
new file mode 100644
index 0000000..1b09154
--- /dev/null
+++ b/app/views/passwords_mailer/reset.html.erb
@@ -0,0 +1,6 @@
+
+ You can reset your password on
+ <%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
+
+ This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
+
diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb
new file mode 100644
index 0000000..aecee82
--- /dev/null
+++ b/app/views/passwords_mailer/reset.text.erb
@@ -0,0 +1,4 @@
+You can reset your password on
+<%= edit_password_url(@user.password_reset_token) %>
+
+This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb
new file mode 100644
index 0000000..1ae25a1
--- /dev/null
+++ b/app/views/registrations/new.html.erb
@@ -0,0 +1,37 @@
+<% content_for :title, "Sign up — FleetPilot" %>
+
+
+
Create your FleetPilot account
+
Manage your fleet: vehicles, drivers, maintenance and alerts.
+
+ <%= form_with model: @registration, url: registration_path, class: "space-y-4" do |form| %>
+ <%= render "shared/form_errors", record: @registration %>
+
+
+ <%= form.label :account_name, "Company name", class: "block text-sm font-medium" %>
+ <%= form.text_field :account_name, required: true, autofocus: true,
+ class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :email_address, "Email", class: "block text-sm font-medium" %>
+ <%= form.email_field :email_address, required: true, autocomplete: "username",
+ class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :password, class: "block text-sm font-medium" %>
+ <%= form.password_field :password, required: true, autocomplete: "new-password", maxlength: 72,
+ class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :password_confirmation, "Confirm password", class: "block text-sm font-medium" %>
+ <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", maxlength: 72,
+ class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.submit "Create account", class: "w-full rounded-md bg-slate-900 px-4 py-2 font-medium text-white hover:bg-slate-700 cursor-pointer" %>
+ <% end %>
+
+
+ Already have an account? <%= link_to "Sign in", new_session_path, class: "font-medium text-slate-900 underline" %>
+
+
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
new file mode 100644
index 0000000..18e45ef
--- /dev/null
+++ b/app/views/sessions/new.html.erb
@@ -0,0 +1,25 @@
+<% content_for :title, "Sign in — FleetPilot" %>
+
+
+
Sign in to FleetPilot
+
+ <%= form_with url: session_path, class: "space-y-4" do |form| %>
+
+ <%= form.label :email_address, "Email", class: "block text-sm font-medium" %>
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username",
+ value: params[:email_address], class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :password, class: "block text-sm font-medium" %>
+ <%= form.password_field :password, required: true, autocomplete: "current-password", maxlength: 72,
+ class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.submit "Sign in", class: "w-full rounded-md bg-slate-900 px-4 py-2 font-medium text-white hover:bg-slate-700 cursor-pointer" %>
+ <% end %>
+
+
+ <%= link_to "Forgot password?", new_password_path, class: "underline" %>
+ <%= link_to "Create an account", new_registration_path, class: "font-medium text-slate-900 underline" %>
+
+
diff --git a/app/views/shared/_form_errors.html.erb b/app/views/shared/_form_errors.html.erb
new file mode 100644
index 0000000..b736c3b
--- /dev/null
+++ b/app/views/shared/_form_errors.html.erb
@@ -0,0 +1,10 @@
+<% if record.errors.any? %>
+
+
<%= pluralize(record.errors.count, "error") %> prevented saving:
+
+ <% record.errors.full_messages.each do |message| %>
+ <%= message %>
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/vehicles/_form.html.erb b/app/views/vehicles/_form.html.erb
new file mode 100644
index 0000000..f53ac3a
--- /dev/null
+++ b/app/views/vehicles/_form.html.erb
@@ -0,0 +1,63 @@
+<%= form_with model: vehicle, class: "max-w-xl space-y-4" do |form| %>
+ <%= render "shared/form_errors", record: vehicle %>
+
+
+ <%= form.label :vin, "VIN", class: "block text-sm font-medium" %>
+ <%= form.text_field :vin, required: true, maxlength: Vehicle::VIN_LENGTH,
+ class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2 font-mono uppercase" %>
+
Make, model and year are filled in automatically from the VIN — leave them blank unless you want to override.
+
+
+
+
+ <%= form.label :make, class: "block text-sm font-medium" %>
+ <%= form.text_field :make, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :model, class: "block text-sm font-medium" %>
+ <%= form.text_field :model, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :year, class: "block text-sm font-medium" %>
+ <%= form.number_field :year, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
+
+
+ <%= form.label :license_plate, class: "block text-sm font-medium" %>
+ <%= form.text_field :license_plate, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2 uppercase" %>
+
+
+ <%= form.label :status, class: "block text-sm font-medium" %>
+ <%= form.select :status, Vehicle.statuses.keys.map { |s| [ s.humanize, s ] },
+ {}, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :fuel_type, class: "block text-sm font-medium" %>
+ <%= form.select :fuel_type, Vehicle.fuel_types.keys.map { |f| [ f.humanize, f ] },
+ { include_blank: "Unknown" }, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
+
+
+ <%= form.label :odometer, "Odometer (mi)", class: "block text-sm font-medium" %>
+ <%= form.number_field :odometer, min: 0, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :purchased_on, class: "block text-sm font-medium" %>
+ <%= form.date_field :purchased_on, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :purchase_price_cents, "Purchase price (cents)", class: "block text-sm font-medium" %>
+ <%= form.number_field :purchase_price_cents, min: 0, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
+
+ <%= form.submit class: "rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700 cursor-pointer" %>
+ <%= link_to "Cancel", vehicle.persisted? ? vehicle_path(vehicle) : vehicles_path,
+ class: "rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100" %>
+
+<% end %>
diff --git a/app/views/vehicles/_status_badge.html.erb b/app/views/vehicles/_status_badge.html.erb
new file mode 100644
index 0000000..171d7c6
--- /dev/null
+++ b/app/views/vehicles/_status_badge.html.erb
@@ -0,0 +1,8 @@
+<% classes = {
+ "active" => "bg-green-100 text-green-800",
+ "in_shop" => "bg-amber-100 text-amber-800",
+ "retired" => "bg-slate-200 text-slate-600"
+}[vehicle.status] %>
+
+ <%= vehicle.status.humanize %>
+
diff --git a/app/views/vehicles/edit.html.erb b/app/views/vehicles/edit.html.erb
new file mode 100644
index 0000000..54a67db
--- /dev/null
+++ b/app/views/vehicles/edit.html.erb
@@ -0,0 +1,4 @@
+<% content_for :title, "Edit vehicle — FleetPilot" %>
+
+Edit <%= @vehicle.display_name %>
+<%= render "form", vehicle: @vehicle %>
diff --git a/app/views/vehicles/index.html.erb b/app/views/vehicles/index.html.erb
new file mode 100644
index 0000000..4642d06
--- /dev/null
+++ b/app/views/vehicles/index.html.erb
@@ -0,0 +1,64 @@
+<% content_for :title, "Vehicles — FleetPilot" %>
+
+
+
Vehicles
+ <% if policy(Vehicle).create? %>
+ <%= link_to "Add vehicle", new_vehicle_path,
+ class: "rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700" %>
+ <% end %>
+
+
+<%= form_with url: vehicles_path, method: :get, data: { controller: "autosubmit" },
+ class: "mb-6 flex flex-wrap items-end gap-3" do |form| %>
+
+ <%= form.label :q, "Search", class: "block text-xs font-medium text-slate-500" %>
+ <%= form.search_field :q, value: params[:q], placeholder: "VIN, make, model or plate",
+ data: { action: "input->autosubmit#submit" },
+ class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" %>
+
+
+ <%= form.label :status, class: "block text-xs font-medium text-slate-500" %>
+ <%= form.select :status,
+ options_for_select(Vehicle.statuses.keys.map { |s| [ s.humanize, s ] }, params[:status]),
+ { include_blank: "All statuses" },
+ data: { action: "change->autosubmit#submit" },
+ class: "mt-1 rounded-md border border-slate-300 px-3 py-2 text-sm" %>
+
+<% end %>
+
+<% if @vehicles.any? %>
+
+
+
+
+ Vehicle
+ VIN
+ Plate
+ Status
+ Odometer
+ Driver
+
+
+
+ <% @vehicles.each do |vehicle| %>
+
+
+ <%= link_to vehicle.display_name, vehicle, class: "hover:underline" %>
+
+ <%= vehicle.vin %>
+ <%= vehicle.license_plate %>
+ <%= render "status_badge", vehicle: vehicle %>
+ <%= number_with_delimiter(vehicle.odometer) %> mi
+ <%= vehicle.current_driver&.name || "—" %>
+
+ <% end %>
+
+
+
+ <%== @pagy.series_nav if @pagy.pages > 1 %>
+<% else %>
+
+
No vehicles found
+
Add your first vehicle to start tracking your fleet.
+
+<% end %>
diff --git a/app/views/vehicles/new.html.erb b/app/views/vehicles/new.html.erb
new file mode 100644
index 0000000..141b09d
--- /dev/null
+++ b/app/views/vehicles/new.html.erb
@@ -0,0 +1,4 @@
+<% content_for :title, "Add vehicle — FleetPilot" %>
+
+Add vehicle
+<%= render "form", vehicle: @vehicle %>
diff --git a/app/views/vehicles/show.html.erb b/app/views/vehicles/show.html.erb
new file mode 100644
index 0000000..b62024f
--- /dev/null
+++ b/app/views/vehicles/show.html.erb
@@ -0,0 +1,72 @@
+<% content_for :title, "#{@vehicle.display_name} — FleetPilot" %>
+
+
+
+
<%= @vehicle.display_name %>
+
VIN <%= @vehicle.vin %>
+
+
+ <% if policy(@vehicle).update? %>
+ <%= link_to "Edit", edit_vehicle_path(@vehicle),
+ class: "rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100" %>
+ <% end %>
+ <% if policy(@vehicle).destroy? %>
+ <%= button_to "Delete", @vehicle, method: :delete,
+ data: { turbo_confirm: "Remove this vehicle and its history?" },
+ class: "rounded-md border border-red-200 px-4 py-2 text-sm text-red-700 hover:bg-red-50 cursor-pointer" %>
+ <% end %>
+
+
+
+
+
+
Details
+
+
Status <%= render "status_badge", vehicle: @vehicle %>
+
License plate <%= @vehicle.license_plate || "—" %>
+
Odometer <%= number_with_delimiter(@vehicle.odometer) %> mi
+
Fuel <%= @vehicle.fuel_type&.humanize || "—" %>
+
Purchased <%= @vehicle.purchased_on&.to_fs(:long) || "—" %>
+
Purchase price <%= money(@vehicle.purchase_price_cents) %>
+
+
+
+
+
+
Assignment history
+ <% if policy(Assignment).create? %>
+ <%= link_to "Assign driver", new_assignment_path(vehicle_id: @vehicle.id),
+ class: "rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-700" %>
+ <% end %>
+
+
+ <% if @assignments.any? %>
+
+
+
+ Driver
+ From
+ To
+
+
+
+
+ <% @assignments.each do |assignment| %>
+
+ <%= link_to assignment.driver.name, assignment.driver, class: "hover:underline" %>
+ <%= assignment.started_on.to_fs(:long) %>
+ <%= assignment.ended_on&.to_fs(:long) || content_tag(:span, "Current", class: "rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800") %>
+
+ <% if policy(assignment).update? %>
+ <%= link_to "Edit", edit_assignment_path(assignment), class: "text-slate-500 underline hover:text-slate-900" %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <% else %>
+
No drivers have been assigned to this vehicle yet.
+ <% end %>
+
+
diff --git a/bin/dev b/bin/dev
index ad72c7d..010e6ba 100755
--- a/bin/dev
+++ b/bin/dev
@@ -1,10 +1,5 @@
#!/usr/bin/env sh
-if ! gem list foreman -i --silent; then
- echo "Installing foreman..."
- gem install foreman
-fi
-
# Default to port 3000 if not specified
export PORT="${PORT:-3000}"
@@ -13,4 +8,6 @@ export PORT="${PORT:-3000}"
export RUBY_DEBUG_OPEN="true"
export RUBY_DEBUG_LAZY="true"
-exec foreman start -f Procfile.dev "$@"
+# Foreman lives in the Gemfile (development group) so it works with
+# project-local bundler paths; a global `gem install foreman` does not.
+exec bundle exec foreman start -f Procfile.dev "$@"
diff --git a/config/routes.rb b/config/routes.rb
index 48254e8..9082626 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,14 +1,15 @@
Rails.application.routes.draw do
- # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
+ resource :session
+ resource :registration, only: %i[new create]
+ resources :passwords, param: :token
+
+ resources :vehicles
+ resources :drivers
+ resources :assignments, only: %i[new create edit update destroy]
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
- # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
- # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
- # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
-
- # Defines the root path route ("/")
- # root "posts#index"
+ root "vehicles#index"
end
diff --git a/db/migrate/20260611225520_create_accounts.rb b/db/migrate/20260611225520_create_accounts.rb
new file mode 100644
index 0000000..92c4bfd
--- /dev/null
+++ b/db/migrate/20260611225520_create_accounts.rb
@@ -0,0 +1,9 @@
+class CreateAccounts < ActiveRecord::Migration[8.1]
+ def change
+ create_table :accounts do |t|
+ t.string :name, null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260611225524_create_users.rb b/db/migrate/20260611225524_create_users.rb
new file mode 100644
index 0000000..3a14285
--- /dev/null
+++ b/db/migrate/20260611225524_create_users.rb
@@ -0,0 +1,14 @@
+class CreateUsers < ActiveRecord::Migration[8.1]
+ def change
+ create_table :users do |t|
+ t.references :account, null: false, foreign_key: true
+ t.string :email_address, null: false
+ t.string :password_digest, null: false
+ t.integer :role, null: false, default: 0
+ t.string :phone
+
+ t.timestamps
+ end
+ add_index :users, :email_address, unique: true
+ end
+end
diff --git a/db/migrate/20260611225525_create_sessions.rb b/db/migrate/20260611225525_create_sessions.rb
new file mode 100644
index 0000000..ec9efdb
--- /dev/null
+++ b/db/migrate/20260611225525_create_sessions.rb
@@ -0,0 +1,11 @@
+class CreateSessions < ActiveRecord::Migration[8.1]
+ def change
+ create_table :sessions do |t|
+ t.references :user, null: false, foreign_key: true
+ t.string :ip_address
+ t.string :user_agent
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260611225653_create_vehicles.rb b/db/migrate/20260611225653_create_vehicles.rb
new file mode 100644
index 0000000..f380f0a
--- /dev/null
+++ b/db/migrate/20260611225653_create_vehicles.rb
@@ -0,0 +1,21 @@
+class CreateVehicles < ActiveRecord::Migration[8.1]
+ def change
+ create_table :vehicles do |t|
+ t.references :account, null: false, foreign_key: true
+ t.string :vin, null: false, limit: 17
+ t.string :make
+ t.string :model
+ t.integer :year
+ t.string :license_plate
+ t.integer :status, null: false, default: 0
+ t.integer :odometer, null: false, default: 0
+ t.integer :fuel_type
+ t.date :purchased_on
+ t.integer :purchase_price_cents
+
+ t.timestamps
+ end
+ add_index :vehicles, [ :account_id, :vin ], unique: true
+ add_index :vehicles, [ :account_id, :status ]
+ end
+end
diff --git a/db/migrate/20260611225654_create_drivers.rb b/db/migrate/20260611225654_create_drivers.rb
new file mode 100644
index 0000000..61c8b10
--- /dev/null
+++ b/db/migrate/20260611225654_create_drivers.rb
@@ -0,0 +1,16 @@
+class CreateDrivers < ActiveRecord::Migration[8.1]
+ def change
+ create_table :drivers do |t|
+ t.references :account, null: false, foreign_key: true
+ t.string :name, null: false
+ t.string :email
+ t.string :phone
+ t.string :license_number
+ t.date :license_expires_on
+ t.integer :status, null: false, default: 0
+
+ t.timestamps
+ end
+ add_index :drivers, [ :account_id, :status ]
+ end
+end
diff --git a/db/migrate/20260611225655_create_assignments.rb b/db/migrate/20260611225655_create_assignments.rb
new file mode 100644
index 0000000..1b53c53
--- /dev/null
+++ b/db/migrate/20260611225655_create_assignments.rb
@@ -0,0 +1,15 @@
+class CreateAssignments < ActiveRecord::Migration[8.1]
+ def change
+ create_table :assignments do |t|
+ t.references :account, null: false, foreign_key: true
+ t.references :vehicle, null: false, foreign_key: true
+ t.references :driver, null: false, foreign_key: true
+ t.date :started_on, null: false
+ t.date :ended_on
+
+ t.timestamps
+ end
+ add_index :assignments, [ :vehicle_id, :started_on ]
+ add_index :assignments, [ :driver_id, :started_on ]
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 03e7368..9a7f350 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,5 +10,87 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 0) do
+ActiveRecord::Schema[8.1].define(version: 2026_06_11_225655) do
+ create_table "accounts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "name", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "assignments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.datetime "created_at", null: false
+ t.bigint "driver_id", null: false
+ t.date "ended_on"
+ t.date "started_on", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "vehicle_id", null: false
+ t.index ["account_id"], name: "index_assignments_on_account_id"
+ t.index ["driver_id", "started_on"], name: "index_assignments_on_driver_id_and_started_on"
+ t.index ["driver_id"], name: "index_assignments_on_driver_id"
+ t.index ["vehicle_id", "started_on"], name: "index_assignments_on_vehicle_id_and_started_on"
+ t.index ["vehicle_id"], name: "index_assignments_on_vehicle_id"
+ end
+
+ create_table "drivers", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.datetime "created_at", null: false
+ t.string "email"
+ t.date "license_expires_on"
+ t.string "license_number"
+ t.string "name", null: false
+ t.string "phone"
+ t.integer "status", default: 0, null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "status"], name: "index_drivers_on_account_id_and_status"
+ t.index ["account_id"], name: "index_drivers_on_account_id"
+ end
+
+ create_table "sessions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "ip_address"
+ t.datetime "updated_at", null: false
+ t.string "user_agent"
+ t.bigint "user_id", null: false
+ t.index ["user_id"], name: "index_sessions_on_user_id"
+ end
+
+ create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.datetime "created_at", null: false
+ t.string "email_address", null: false
+ t.string "password_digest", null: false
+ t.string "phone"
+ t.integer "role", default: 0, null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_users_on_account_id"
+ t.index ["email_address"], name: "index_users_on_email_address", unique: true
+ end
+
+ create_table "vehicles", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.datetime "created_at", null: false
+ t.integer "fuel_type"
+ t.string "license_plate"
+ t.string "make"
+ t.string "model"
+ t.integer "odometer", default: 0, null: false
+ t.integer "purchase_price_cents"
+ t.date "purchased_on"
+ t.integer "status", default: 0, null: false
+ t.datetime "updated_at", null: false
+ t.string "vin", limit: 17, null: false
+ t.integer "year"
+ t.index ["account_id", "status"], name: "index_vehicles_on_account_id_and_status"
+ t.index ["account_id", "vin"], name: "index_vehicles_on_account_id_and_vin", unique: true
+ t.index ["account_id"], name: "index_vehicles_on_account_id"
+ end
+
+ add_foreign_key "assignments", "accounts"
+ add_foreign_key "assignments", "drivers"
+ add_foreign_key "assignments", "vehicles"
+ add_foreign_key "drivers", "accounts"
+ add_foreign_key "sessions", "users"
+ add_foreign_key "users", "accounts"
+ add_foreign_key "vehicles", "accounts"
end
diff --git a/db/seeds.rb b/db/seeds.rb
index 4fbd6ed..4154ed1 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -1,9 +1,68 @@
-# This file should ensure the existence of records required to run the application in every environment (production,
-# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
-# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
-#
-# Example:
-#
-# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
-# MovieGenre.find_or_create_by!(name: genre_name)
-# end
+# Demo data: one account with a small fleet. Idempotent — safe to re-run.
+require "faker"
+
+account = Account.find_or_create_by!(name: "Acme Logistics")
+
+admin = account.users.find_or_create_by!(email_address: "admin@fleetpilot.test") do |user|
+ user.password = "password123"
+ user.role = :admin
+end
+
+account.users.find_or_create_by!(email_address: "viewer@fleetpilot.test") do |user|
+ user.password = "password123"
+ user.role = :viewer
+end
+
+MAKES_AND_MODELS = {
+ "Ford" => %w[Transit F-150 Ranger],
+ "Toyota" => %w[Hilux HiAce Corolla],
+ "Mercedes-Benz" => %w[Sprinter Vito],
+ "Chevrolet" => %w[Silverado Express]
+}.freeze
+
+VIN_CHARS = ("A".."Z").to_a - %w[I O Q] + ("0".."9").to_a
+
+if account.vehicles.none?
+ 20.times do
+ make = MAKES_AND_MODELS.keys.sample
+ account.vehicles.create!(
+ vin: Array.new(17) { VIN_CHARS.sample }.join,
+ make: make,
+ model: MAKES_AND_MODELS[make].sample,
+ year: rand(2015..2025),
+ license_plate: "#{('A'..'Z').to_a.sample(3).join}#{rand(100..999)}",
+ status: [ :active, :active, :active, :in_shop, :retired ].sample,
+ odometer: rand(5_000..180_000),
+ fuel_type: Vehicle.fuel_types.keys.sample,
+ purchased_on: Faker::Date.between(from: 8.years.ago, to: 1.year.ago),
+ purchase_price_cents: rand(15_000..65_000) * 100
+ )
+ end
+end
+
+if account.drivers.none?
+ 15.times do
+ account.drivers.create!(
+ name: Faker::Name.name,
+ email: Faker::Internet.unique.email,
+ phone: Faker::PhoneNumber.cell_phone_in_e164,
+ license_number: "DL#{rand(1_000_000..9_999_999)}",
+ license_expires_on: Faker::Date.between(from: 2.months.ago, to: 4.years.from_now),
+ status: rand < 0.9 ? :active : :inactive
+ )
+ end
+end
+
+if account.assignments.none?
+ drivers = account.drivers.active.to_a.shuffle
+ account.vehicles.active.limit(drivers.size).each_with_index do |vehicle, i|
+ vehicle.assignments.create!(
+ account: account,
+ driver: drivers[i],
+ started_on: Faker::Date.between(from: 18.months.ago, to: 1.month.ago)
+ )
+ end
+end
+
+puts "Seeded: #{Account.count} accounts, #{Vehicle.count} vehicles, #{Driver.count} drivers, #{Assignment.count} assignments"
+puts "Sign in as #{admin.email_address} / password123"
diff --git a/docs/POSTMORTEMS.md b/docs/POSTMORTEMS.md
index c8989f7..7564c66 100644
--- a/docs/POSTMORTEMS.md
+++ b/docs/POSTMORTEMS.md
@@ -16,6 +16,13 @@ Template:
---
+## 2026-06-11 — `bin/dev` fails with `foreman: not found`
+
+- **Symptom:** `bin/dev` tried to `gem install foreman`, which landed in `~/.local/share/gem/.../bin` (not on PATH) and failed to rehash rbenv shims, then `exec foreman` failed with `foreman: not found`.
+- **Root cause:** the stock `bin/dev` installs foreman as a global gem, but rbenv is root-owned (gems install to a user dir outside PATH) and foreman isn't in the bundle.
+- **Fix:** added `foreman` to the Gemfile `development` group and changed `bin/dev` to `exec bundle exec foreman start -f Procfile.dev`.
+- **Prevention rule:** on this machine, dev tools that scripts expect on PATH (foreman, etc.) must live in the Gemfile and be invoked via `bundle exec`, never `gem install`. Same root cause as the vendored-gems entry below.
+
## 2026-06-11 — git push rejected for workflow files (missing OAuth scope)
- **Symptom:** `git push` rejected with `refusing to allow an OAuth App to create or update workflow .github/workflows/ci.yml without workflow scope`.
diff --git a/spec/factories/accounts.rb b/spec/factories/accounts.rb
new file mode 100644
index 0000000..03321c8
--- /dev/null
+++ b/spec/factories/accounts.rb
@@ -0,0 +1,5 @@
+FactoryBot.define do
+ factory :account do
+ sequence(:name) { |n| "Acme Logistics #{n}" }
+ end
+end
diff --git a/spec/factories/assignments.rb b/spec/factories/assignments.rb
new file mode 100644
index 0000000..1f44e8c
--- /dev/null
+++ b/spec/factories/assignments.rb
@@ -0,0 +1,9 @@
+FactoryBot.define do
+ factory :assignment do
+ account
+ vehicle { association :vehicle, account: account }
+ driver { association :driver, account: account }
+ started_on { Date.current }
+ ended_on { nil }
+ end
+end
diff --git a/spec/factories/drivers.rb b/spec/factories/drivers.rb
new file mode 100644
index 0000000..9eb4852
--- /dev/null
+++ b/spec/factories/drivers.rb
@@ -0,0 +1,11 @@
+FactoryBot.define do
+ factory :driver do
+ account
+ name { Faker::Name.name }
+ sequence(:email) { |n| "driver#{n}@example.com" }
+ phone { "+15555550100" }
+ sequence(:license_number) { |n| "DL#{n.to_s.rjust(7, '0')}" }
+ license_expires_on { 2.years.from_now.to_date }
+ status { :active }
+ end
+end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
new file mode 100644
index 0000000..b3f45f0
--- /dev/null
+++ b/spec/factories/users.rb
@@ -0,0 +1,16 @@
+FactoryBot.define do
+ factory :user do
+ account
+ sequence(:email_address) { |n| "user#{n}@example.com" }
+ password { "password123" }
+ role { :manager }
+
+ trait :admin do
+ role { :admin }
+ end
+
+ trait :viewer do
+ role { :viewer }
+ end
+ end
+end
diff --git a/spec/factories/vehicles.rb b/spec/factories/vehicles.rb
new file mode 100644
index 0000000..d76759a
--- /dev/null
+++ b/spec/factories/vehicles.rb
@@ -0,0 +1,12 @@
+FactoryBot.define do
+ factory :vehicle do
+ account
+ sequence(:vin) { |n| "1HGCM82633A#{n.to_s.rjust(6, '0')}" }
+ make { "Honda" }
+ model { "Accord" }
+ year { 2022 }
+ sequence(:license_plate) { |n| "FLT#{n.to_s.rjust(4, '0')}" }
+ status { :active }
+ odometer { 12_500 }
+ end
+end
diff --git a/spec/jobs/vin_decode_job_spec.rb b/spec/jobs/vin_decode_job_spec.rb
new file mode 100644
index 0000000..0174505
--- /dev/null
+++ b/spec/jobs/vin_decode_job_spec.rb
@@ -0,0 +1,31 @@
+require 'rails_helper'
+
+RSpec.describe VinDecodeJob do
+ let(:vehicle) { create(:vehicle, make: nil, model: nil, year: nil) }
+
+ it "fills in blank attributes from the decoder" do
+ allow(VinDecoder).to receive(:decode).with(vehicle.vin)
+ .and_return(VinDecoder::Result.new(make: "Honda", model: "Accord", year: 2003))
+
+ described_class.perform_now(vehicle)
+
+ expect(vehicle.reload).to have_attributes(make: "Honda", model: "Accord", year: 2003)
+ end
+
+ it "does not overwrite user-entered data" do
+ vehicle.update!(make: "Custom Make")
+ allow(VinDecoder).to receive(:decode)
+ .and_return(VinDecoder::Result.new(make: "Honda", model: "Accord", year: 2003))
+
+ described_class.perform_now(vehicle)
+
+ expect(vehicle.reload.make).to eq("Custom Make")
+ expect(vehicle.model).to eq("Accord")
+ end
+
+ it "leaves the vehicle untouched when decoding fails" do
+ allow(VinDecoder).to receive(:decode).and_return(nil)
+
+ expect { described_class.perform_now(vehicle) }.not_to change { vehicle.reload.updated_at }
+ end
+end
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
new file mode 100644
index 0000000..20d4305
--- /dev/null
+++ b/spec/models/account_spec.rb
@@ -0,0 +1,9 @@
+require 'rails_helper'
+
+RSpec.describe Account, type: :model do
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to have_many(:users).dependent(:destroy) }
+ it { is_expected.to have_many(:vehicles).dependent(:destroy) }
+ it { is_expected.to have_many(:drivers).dependent(:destroy) }
+ it { is_expected.to have_many(:assignments).dependent(:destroy) }
+end
diff --git a/spec/models/assignment_spec.rb b/spec/models/assignment_spec.rb
new file mode 100644
index 0000000..b32931b
--- /dev/null
+++ b/spec/models/assignment_spec.rb
@@ -0,0 +1,78 @@
+require 'rails_helper'
+
+RSpec.describe Assignment, type: :model do
+ it { is_expected.to belong_to(:account) }
+ it { is_expected.to belong_to(:vehicle) }
+ it { is_expected.to belong_to(:driver) }
+ it { is_expected.to validate_presence_of(:started_on) }
+
+ it "rejects an end date before the start date" do
+ assignment = build(:assignment, started_on: Date.current, ended_on: 1.week.ago.to_date)
+ expect(assignment).not_to be_valid
+ expect(assignment.errors[:ended_on]).to be_present
+ end
+
+ describe "tenant integrity" do
+ it "rejects a vehicle from another account" do
+ assignment = build(:assignment, vehicle: create(:vehicle))
+ expect(assignment).not_to be_valid
+ expect(assignment.errors[:vehicle]).to include("belongs to another account")
+ end
+
+ it "rejects a driver from another account" do
+ assignment = build(:assignment, driver: create(:driver))
+ expect(assignment).not_to be_valid
+ expect(assignment.errors[:driver]).to include("belongs to another account")
+ end
+ end
+
+ describe "overlap validation" do
+ let(:existing) do
+ create(:assignment, started_on: Date.new(2026, 1, 1), ended_on: Date.new(2026, 3, 31))
+ end
+
+ it "rejects a period overlapping the same vehicle" do
+ conflicting = build(:assignment,
+ account: existing.account,
+ vehicle: existing.vehicle,
+ driver: create(:driver, account: existing.account),
+ started_on: Date.new(2026, 3, 1))
+ expect(conflicting).not_to be_valid
+ expect(conflicting.errors[:vehicle]).to include("is already assigned during this period")
+ end
+
+ it "rejects a period overlapping the same driver" do
+ conflicting = build(:assignment,
+ account: existing.account,
+ vehicle: create(:vehicle, account: existing.account),
+ driver: existing.driver,
+ started_on: Date.new(2026, 2, 1), ended_on: Date.new(2026, 2, 15))
+ expect(conflicting).not_to be_valid
+ expect(conflicting.errors[:driver]).to include("is already assigned during this period")
+ end
+
+ it "treats an open-ended existing assignment as blocking" do
+ open_ended = create(:assignment, started_on: Date.new(2026, 1, 1))
+ conflicting = build(:assignment,
+ account: open_ended.account,
+ vehicle: open_ended.vehicle,
+ driver: create(:driver, account: open_ended.account),
+ started_on: Date.new(2027, 6, 1))
+ expect(conflicting).not_to be_valid
+ end
+
+ it "allows back-to-back periods that do not overlap" do
+ following = build(:assignment,
+ account: existing.account,
+ vehicle: existing.vehicle,
+ driver: create(:driver, account: existing.account),
+ started_on: Date.new(2026, 4, 1), ended_on: Date.new(2026, 6, 30))
+ expect(following).to be_valid
+ end
+
+ it "ignores itself when updating" do
+ existing.ended_on = Date.new(2026, 4, 15)
+ expect(existing).to be_valid
+ end
+ end
+end
diff --git a/spec/models/driver_spec.rb b/spec/models/driver_spec.rb
new file mode 100644
index 0000000..3f7a02e
--- /dev/null
+++ b/spec/models/driver_spec.rb
@@ -0,0 +1,23 @@
+require 'rails_helper'
+
+RSpec.describe Driver, type: :model do
+ it { is_expected.to belong_to(:account) }
+ it { is_expected.to have_many(:assignments).dependent(:destroy) }
+ it { is_expected.to validate_presence_of(:name) }
+
+ it "rejects malformed emails but allows blank ones" do
+ expect(build(:driver, email: "not-an-email")).not_to be_valid
+ expect(build(:driver, email: "")).to be_valid
+ end
+
+ describe "#license_expired?" do
+ it "is true when the expiry date is in the past" do
+ expect(build(:driver, license_expires_on: Date.yesterday).license_expired?).to be(true)
+ end
+
+ it "is false when there is no expiry date or it is in the future" do
+ expect(build(:driver, license_expires_on: nil).license_expired?).to be(false)
+ expect(build(:driver, license_expires_on: Date.tomorrow).license_expired?).to be(false)
+ end
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
new file mode 100644
index 0000000..f28efe1
--- /dev/null
+++ b/spec/models/user_spec.rb
@@ -0,0 +1,19 @@
+require 'rails_helper'
+
+RSpec.describe User, type: :model do
+ subject { build(:user) }
+
+ it { is_expected.to belong_to(:account) }
+ it { is_expected.to validate_presence_of(:email_address) }
+ it { is_expected.to validate_uniqueness_of(:email_address).case_insensitive }
+ it { is_expected.to define_enum_for(:role).with_values(viewer: 0, manager: 1, admin: 2) }
+
+ it "normalizes the email address" do
+ user = create(:user, email_address: " Fleet.Admin@EXAMPLE.com ")
+ expect(user.email_address).to eq("fleet.admin@example.com")
+ end
+
+ it "defaults to the viewer role" do
+ expect(User.new.role).to eq("viewer")
+ end
+end
diff --git a/spec/models/vehicle_spec.rb b/spec/models/vehicle_spec.rb
new file mode 100644
index 0000000..c5bb850
--- /dev/null
+++ b/spec/models/vehicle_spec.rb
@@ -0,0 +1,62 @@
+require 'rails_helper'
+
+RSpec.describe Vehicle, type: :model do
+ subject { build(:vehicle) }
+
+ it { is_expected.to belong_to(:account) }
+ it { is_expected.to have_many(:assignments).dependent(:destroy) }
+ it { is_expected.to validate_presence_of(:vin) }
+ it { is_expected.to validate_length_of(:vin).is_equal_to(17) }
+ it { is_expected.to validate_uniqueness_of(:vin).scoped_to(:account_id).ignoring_case_sensitivity }
+
+ it "normalizes the VIN to uppercase" do
+ vehicle = build(:vehicle, vin: " 1hgcm82633a004352 ")
+ expect(vehicle.vin).to eq("1HGCM82633A004352")
+ end
+
+ it "rejects VINs with the forbidden characters I, O and Q" do
+ vehicle = build(:vehicle, vin: "IOQCM82633A004352")
+ expect(vehicle).not_to be_valid
+ expect(vehicle.errors[:vin]).to be_present
+ end
+
+ it "allows the same VIN on different accounts" do
+ existing = create(:vehicle)
+ twin = build(:vehicle, vin: existing.vin)
+ expect(twin).to be_valid
+ end
+
+ describe ".search" do
+ it "matches VIN, make, model and plate" do
+ vehicle = create(:vehicle, make: "Toyota", model: "Hilux", license_plate: "XYZ1234")
+ create(:vehicle, account: vehicle.account, make: "Ford", model: "Transit")
+
+ expect(Vehicle.search("hilux")).to contain_exactly(vehicle)
+ expect(Vehicle.search("XYZ")).to contain_exactly(vehicle)
+ expect(Vehicle.search(nil).count).to eq(2)
+ end
+ end
+
+ describe "#display_name" do
+ it "combines year, make and model" do
+ expect(build(:vehicle, year: 2022, make: "Honda", model: "Accord").display_name).to eq("2022 Honda Accord")
+ end
+
+ it "falls back to the VIN when nothing is decoded yet" do
+ vehicle = build(:vehicle, year: nil, make: nil, model: nil, vin: "1HGCM82633A004352")
+ expect(vehicle.display_name).to eq("1HGCM82633A004352")
+ end
+ end
+
+ describe "#current_driver" do
+ it "returns the driver of the open assignment" do
+ assignment = create(:assignment, started_on: 1.month.ago.to_date)
+ expect(assignment.vehicle.current_driver).to eq(assignment.driver)
+ end
+
+ it "ignores ended assignments" do
+ assignment = create(:assignment, started_on: 1.year.ago.to_date, ended_on: 1.month.ago.to_date)
+ expect(assignment.vehicle.current_driver).to be_nil
+ end
+ end
+end
diff --git a/spec/policies/vehicle_policy_spec.rb b/spec/policies/vehicle_policy_spec.rb
new file mode 100644
index 0000000..9e640de
--- /dev/null
+++ b/spec/policies/vehicle_policy_spec.rb
@@ -0,0 +1,26 @@
+require 'rails_helper'
+
+RSpec.describe VehiclePolicy do
+ subject(:policy) { described_class.new(user, vehicle) }
+
+ let(:vehicle) { build_stubbed(:vehicle) }
+
+ context "as a viewer" do
+ let(:user) { build_stubbed(:user, :viewer) }
+
+ it { is_expected.to permit_actions(%i[index show]) }
+ it { is_expected.to forbid_actions(%i[create update destroy]) }
+ end
+
+ context "as a manager" do
+ let(:user) { build_stubbed(:user, role: :manager) }
+
+ it { is_expected.to permit_actions(%i[index show create update destroy]) }
+ end
+
+ context "as an admin" do
+ let(:user) { build_stubbed(:user, :admin) }
+
+ it { is_expected.to permit_actions(%i[index show create update destroy]) }
+ end
+end
diff --git a/spec/requests/assignments_spec.rb b/spec/requests/assignments_spec.rb
new file mode 100644
index 0000000..d457662
--- /dev/null
+++ b/spec/requests/assignments_spec.rb
@@ -0,0 +1,46 @@
+require 'rails_helper'
+
+RSpec.describe "Assignments", type: :request do
+ let(:user) { create(:user) }
+ let(:account) { user.account }
+ let(:vehicle) { create(:vehicle, account: account) }
+ let(:driver) { create(:driver, account: account) }
+
+ before { sign_in user }
+
+ it "assigns a driver to a vehicle" do
+ expect {
+ post assignments_path, params: { assignment: {
+ vehicle_id: vehicle.id, driver_id: driver.id, started_on: Date.current
+ } }
+ }.to change(account.assignments, :count).by(1)
+
+ expect(response).to redirect_to(vehicle_path(vehicle))
+ end
+
+ it "rejects overlapping assignments with an error" do
+ create(:assignment, account: account, vehicle: vehicle, driver: driver, started_on: 1.month.ago.to_date)
+ other_driver = create(:driver, account: account)
+
+ expect {
+ post assignments_path, params: { assignment: {
+ vehicle_id: vehicle.id, driver_id: other_driver.id, started_on: Date.current
+ } }
+ }.not_to change(Assignment, :count)
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.body).to include("already assigned")
+ end
+
+ it "cannot reference a vehicle from another account" do
+ foreign_vehicle = create(:vehicle)
+
+ expect {
+ post assignments_path, params: { assignment: {
+ vehicle_id: foreign_vehicle.id, driver_id: driver.id, started_on: Date.current
+ } }
+ }.not_to change(Assignment, :count)
+
+ expect(response).to have_http_status(:not_found)
+ end
+end
diff --git a/spec/requests/drivers_spec.rb b/spec/requests/drivers_spec.rb
new file mode 100644
index 0000000..3f192d3
--- /dev/null
+++ b/spec/requests/drivers_spec.rb
@@ -0,0 +1,30 @@
+require 'rails_helper'
+
+RSpec.describe "Drivers", type: :request do
+ let(:user) { create(:user) }
+ let(:account) { user.account }
+
+ it "does not expose drivers from other accounts" do
+ foreign_driver = create(:driver)
+ sign_in user
+
+ get driver_path(foreign_driver)
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it "creates a driver" do
+ sign_in user
+
+ expect {
+ post drivers_path, params: { driver: { name: "Alex Driver", email: "alex@acme.test" } }
+ }.to change(account.drivers, :count).by(1)
+ end
+
+ it "forbids viewers from managing drivers" do
+ sign_in create(:user, :viewer)
+ driver = create(:driver, account: Account.last)
+
+ patch driver_path(driver), params: { driver: { name: "Hacked" } }
+ expect(driver.reload.name).not_to eq("Hacked")
+ end
+end
diff --git a/spec/requests/registrations_spec.rb b/spec/requests/registrations_spec.rb
new file mode 100644
index 0000000..1df4b23
--- /dev/null
+++ b/spec/requests/registrations_spec.rb
@@ -0,0 +1,44 @@
+require 'rails_helper'
+
+RSpec.describe "Registrations", type: :request do
+ describe "POST /registration" do
+ it "creates the account with its first admin user and signs them in" do
+ expect {
+ post registration_path, params: { registration: {
+ account_name: "Acme Fleet Co",
+ email_address: "owner@acme.test",
+ password: "password123",
+ password_confirmation: "password123"
+ } }
+ }.to change(Account, :count).by(1).and change(User, :count).by(1)
+
+ expect(response).to redirect_to(root_path)
+ expect(User.last).to have_attributes(role: "admin", account: Account.last)
+
+ follow_redirect!
+ expect(response.body).to include("Acme Fleet Co")
+ end
+
+ it "creates nothing when validation fails" do
+ expect {
+ post registration_path, params: { registration: {
+ account_name: "", email_address: "owner@acme.test", password: "password123",
+ password_confirmation: "password123"
+ } }
+ }.to not_change(Account, :count).and not_change(User, :count)
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ it "rolls the account back when the user is invalid" do
+ expect {
+ post registration_path, params: { registration: {
+ account_name: "Acme Fleet Co", email_address: "bad", password: "password123",
+ password_confirmation: "password123"
+ } }
+ }.to not_change(Account, :count).and not_change(User, :count)
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+end
diff --git a/spec/requests/vehicles_spec.rb b/spec/requests/vehicles_spec.rb
new file mode 100644
index 0000000..2a8a489
--- /dev/null
+++ b/spec/requests/vehicles_spec.rb
@@ -0,0 +1,83 @@
+require 'rails_helper'
+
+RSpec.describe "Vehicles", type: :request do
+ let(:user) { create(:user) }
+ let(:account) { user.account }
+
+ describe "authentication" do
+ it "redirects unauthenticated visitors to sign in" do
+ get vehicles_path
+ expect(response).to redirect_to(new_session_path)
+ end
+ end
+
+ describe "tenant isolation" do
+ it "does not expose vehicles from other accounts" do
+ foreign_vehicle = create(:vehicle)
+ sign_in user
+
+ get vehicle_path(foreign_vehicle)
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it "lists only the account's vehicles" do
+ mine = create(:vehicle, account: account, make: "Toyota", model: "Hilux")
+ create(:vehicle, make: "Ford", model: "Ranger")
+ sign_in user
+
+ get vehicles_path
+ expect(response.body).to include(mine.vin)
+ expect(response.body).not_to include("Ranger")
+ end
+ end
+
+ describe "CRUD" do
+ before { sign_in user }
+
+ it "creates a vehicle and enqueues VIN decoding" do
+ expect {
+ post vehicles_path, params: { vehicle: { vin: "1hgcm82633a004352", odometer: 100 } }
+ }.to change(account.vehicles, :count).by(1)
+ .and have_enqueued_job(VinDecodeJob)
+
+ expect(account.vehicles.last.vin).to eq("1HGCM82633A004352")
+ end
+
+ it "re-renders the form on validation errors" do
+ post vehicles_path, params: { vehicle: { vin: "short" } }
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ it "updates a vehicle" do
+ vehicle = create(:vehicle, account: account)
+ patch vehicle_path(vehicle), params: { vehicle: { status: "in_shop" } }
+ expect(vehicle.reload.status).to eq("in_shop")
+ end
+
+ it "destroys a vehicle" do
+ vehicle = create(:vehicle, account: account)
+ expect { delete vehicle_path(vehicle) }.to change(account.vehicles, :count).by(-1)
+ end
+
+ it "filters by search and status" do
+ hilux = create(:vehicle, account: account, make: "Toyota", model: "Hilux", status: :in_shop)
+ create(:vehicle, account: account, make: "Toyota", model: "Corolla", status: :active)
+
+ get vehicles_path, params: { q: "toyota", status: "in_shop" }
+ expect(response.body).to include(hilux.vin)
+ expect(response.body).not_to include("Corolla")
+ end
+ end
+
+ describe "authorization" do
+ it "forbids viewers from creating vehicles" do
+ sign_in create(:user, :viewer)
+
+ expect {
+ post vehicles_path, params: { vehicle: { vin: "1HGCM82633A004352" } }
+ }.not_to change(Vehicle, :count)
+
+ expect(response).to redirect_to(root_path)
+ end
+ end
+end
diff --git a/spec/services/vin_decoder_spec.rb b/spec/services/vin_decoder_spec.rb
new file mode 100644
index 0000000..18ac83b
--- /dev/null
+++ b/spec/services/vin_decoder_spec.rb
@@ -0,0 +1,40 @@
+require 'rails_helper'
+
+RSpec.describe VinDecoder do
+ let(:vin) { "1HGCM82633A004352" }
+ let(:api_url) { "#{described_class::BASE_URL}/#{vin}?format=json" }
+
+ it "returns make, model and year from the NHTSA payload" do
+ stub_request(:get, api_url).to_return(
+ status: 200,
+ body: { Results: [ { "Make" => "HONDA", "Model" => "Accord", "ModelYear" => "2003" } ] }.to_json
+ )
+
+ result = described_class.decode(vin)
+
+ expect(result.make).to eq("Honda")
+ expect(result.model).to eq("Accord")
+ expect(result.year).to eq(2003)
+ end
+
+ it "returns nil when the API has no data for the VIN" do
+ stub_request(:get, api_url).to_return(
+ status: 200,
+ body: { Results: [ { "Make" => "", "Model" => "", "ModelYear" => "" } ] }.to_json
+ )
+
+ expect(described_class.decode(vin)).to be_nil
+ end
+
+ it "returns nil on HTTP errors" do
+ stub_request(:get, api_url).to_return(status: 503)
+
+ expect(described_class.decode(vin)).to be_nil
+ end
+
+ it "returns nil on timeouts" do
+ stub_request(:get, api_url).to_timeout
+
+ expect(described_class.decode(vin)).to be_nil
+ end
+end
diff --git a/spec/support/authentication_helpers.rb b/spec/support/authentication_helpers.rb
new file mode 100644
index 0000000..18a5c69
--- /dev/null
+++ b/spec/support/authentication_helpers.rb
@@ -0,0 +1,21 @@
+module AuthenticationHelpers
+ module Request
+ def sign_in(user, password: "password123")
+ post session_path, params: { email_address: user.email_address, password: password }
+ end
+ end
+
+ module System
+ def sign_in(user, password: "password123")
+ visit new_session_path
+ fill_in "Email", with: user.email_address
+ fill_in "Password", with: password
+ click_button "Sign in"
+ end
+ end
+end
+
+RSpec.configure do |config|
+ config.include AuthenticationHelpers::Request, type: :request
+ config.include AuthenticationHelpers::System, type: :system
+end
diff --git a/spec/support/negated_matchers.rb b/spec/support/negated_matchers.rb
new file mode 100644
index 0000000..93f5b66
--- /dev/null
+++ b/spec/support/negated_matchers.rb
@@ -0,0 +1 @@
+RSpec::Matchers.define_negated_matcher :not_change, :change
diff --git a/spec/support/pundit_matchers.rb b/spec/support/pundit_matchers.rb
new file mode 100644
index 0000000..c176208
--- /dev/null
+++ b/spec/support/pundit_matchers.rb
@@ -0,0 +1,20 @@
+# Minimal matchers for policy specs.
+RSpec::Matchers.define :permit_actions do |actions|
+ match do |policy|
+ actions.all? { |action| policy.public_send(:"#{action}?") }
+ end
+ failure_message do |policy|
+ denied = actions.reject { |action| policy.public_send(:"#{action}?") }
+ "expected #{policy.class} to permit #{denied.join(', ')}"
+ end
+end
+
+RSpec::Matchers.define :forbid_actions do |actions|
+ match do |policy|
+ actions.none? { |action| policy.public_send(:"#{action}?") }
+ end
+ failure_message do |policy|
+ allowed = actions.select { |action| policy.public_send(:"#{action}?") }
+ "expected #{policy.class} to forbid #{allowed.join(', ')}"
+ end
+end
diff --git a/spec/system/assigning_drivers_spec.rb b/spec/system/assigning_drivers_spec.rb
new file mode 100644
index 0000000..94d113b
--- /dev/null
+++ b/spec/system/assigning_drivers_spec.rb
@@ -0,0 +1,26 @@
+require 'rails_helper'
+
+RSpec.describe "Assigning drivers", type: :system do
+ it "assigns a driver to a vehicle from the vehicle page" do
+ user = create(:user)
+ vehicle = create(:vehicle, account: user.account, make: "Toyota", model: "Hilux", year: 2023)
+ driver = create(:driver, account: user.account, name: "Alex Driver")
+
+ sign_in user
+
+ visit vehicle_path(vehicle)
+ expect(page).to have_content("No drivers have been assigned")
+
+ click_link "Assign driver"
+ select "2023 Toyota Hilux", from: "Vehicle"
+ select "Alex Driver", from: "Driver"
+ fill_in "From", with: Date.current
+ click_button "Create Assignment"
+
+ expect(page).to have_content("Driver assigned")
+ expect(page).to have_content("Alex Driver")
+ expect(page).to have_content("Current")
+
+ expect(driver.reload.current_vehicle).to eq(vehicle)
+ end
+end
diff --git a/spec/system/onboarding_spec.rb b/spec/system/onboarding_spec.rb
new file mode 100644
index 0000000..4bfda97
--- /dev/null
+++ b/spec/system/onboarding_spec.rb
@@ -0,0 +1,24 @@
+require 'rails_helper'
+
+RSpec.describe "Onboarding", type: :system do
+ it "signs up a company and adds the first vehicle" do
+ visit new_registration_path
+
+ fill_in "Company name", with: "Acme Fleet Co"
+ fill_in "Email", with: "owner@acme.test"
+ fill_in "Password", with: "password123", match: :prefer_exact
+ fill_in "Confirm password", with: "password123"
+ click_button "Create account"
+
+ expect(page).to have_content("Welcome to FleetPilot, Acme Fleet Co!")
+ expect(page).to have_content("No vehicles found")
+
+ click_link "Add vehicle"
+ fill_in "VIN", with: "1HGCM82633A004352"
+ fill_in "Odometer (mi)", with: "42000"
+ click_button "Create Vehicle"
+
+ expect(page).to have_content("Vehicle added")
+ expect(page).to have_content("1HGCM82633A004352")
+ end
+end