From 7e80b76cccf36a8c00500012b05e2272856e7439 Mon Sep 17 00:00:00 2001 From: faborubio Date: Thu, 11 Jun 2026 19:18:13 -0400 Subject: [PATCH 1/2] Phase 1: multi-tenant fleet core - Rails 8 native authentication plus signup flow (Registration form object creates the Account and its first admin user in one transaction) - Row-level multi-tenancy: every domain record belongs to an Account and is only reachable through Current.account associations - Pundit role policies: viewer (read-only), manager, admin - Vehicles CRUD with VIN normalization/validation, search + status filters (Stimulus autosubmit), Pagy pagination, and background VIN decoding via the free NHTSA vPIC API (never overwrites user-entered data) - Drivers CRUD with license expiry tracking - Assignments with overlap validation per vehicle and per driver, and cross-account integrity checks - Tailwind UI: app shell, tables, forms, empty states - 73 specs: models, policies, requests (tenant isolation, authorization), service with WebMock, job, and 2 end-to-end system specs Co-Authored-By: Claude Fable 5 --- README.md | 2 +- app/channels/application_cable/connection.rb | 16 ++++ app/controllers/application_controller.rb | 22 +++++ app/controllers/assignments_controller.rb | 54 ++++++++++++ app/controllers/concerns/authentication.rb | 52 ++++++++++++ app/controllers/drivers_controller.rb | 57 +++++++++++++ app/controllers/passwords_controller.rb | 35 ++++++++ app/controllers/registrations_controller.rb | 25 ++++++ app/controllers/sessions_controller.rb | 21 +++++ app/controllers/vehicles_controller.rb | 59 +++++++++++++ app/helpers/application_helper.rb | 10 +++ .../controllers/autosubmit_controller.js | 13 +++ app/jobs/vin_decode_job.rb | 19 +++++ app/mailers/passwords_mailer.rb | 6 ++ app/models/account.rb | 8 ++ app/models/assignment.rb | 48 +++++++++++ app/models/concerns/account_scoped.rb | 9 ++ app/models/current.rb | 5 ++ app/models/driver.rb | 32 +++++++ app/models/registration.rb | 29 +++++++ app/models/session.rb | 3 + app/models/user.rb | 13 +++ app/models/vehicle.rb | 41 +++++++++ app/policies/application_policy.rb | 63 ++++++++++++++ app/policies/assignment_policy.rb | 4 + app/policies/driver_policy.rb | 4 + app/policies/vehicle_policy.rb | 4 + app/services/vin_decoder.rb | 48 +++++++++++ app/views/assignments/_form.html.erb | 32 +++++++ app/views/assignments/edit.html.erb | 11 +++ app/views/assignments/new.html.erb | 4 + app/views/drivers/_form.html.erb | 41 +++++++++ app/views/drivers/edit.html.erb | 4 + app/views/drivers/index.html.erb | 63 ++++++++++++++ app/views/drivers/new.html.erb | 4 + app/views/drivers/show.html.erb | 69 +++++++++++++++ app/views/layouts/application.html.erb | 33 ++++++-- app/views/passwords/edit.html.erb | 21 +++++ app/views/passwords/new.html.erb | 17 ++++ app/views/passwords_mailer/reset.html.erb | 6 ++ app/views/passwords_mailer/reset.text.erb | 4 + app/views/registrations/new.html.erb | 37 ++++++++ app/views/sessions/new.html.erb | 25 ++++++ app/views/shared/_form_errors.html.erb | 10 +++ app/views/vehicles/_form.html.erb | 63 ++++++++++++++ app/views/vehicles/_status_badge.html.erb | 8 ++ app/views/vehicles/edit.html.erb | 4 + app/views/vehicles/index.html.erb | 64 ++++++++++++++ app/views/vehicles/new.html.erb | 4 + app/views/vehicles/show.html.erb | 72 ++++++++++++++++ config/routes.rb | 15 ++-- db/migrate/20260611225520_create_accounts.rb | 9 ++ db/migrate/20260611225524_create_users.rb | 14 ++++ db/migrate/20260611225525_create_sessions.rb | 11 +++ db/migrate/20260611225653_create_vehicles.rb | 21 +++++ db/migrate/20260611225654_create_drivers.rb | 16 ++++ .../20260611225655_create_assignments.rb | 15 ++++ db/schema.rb | 84 ++++++++++++++++++- db/seeds.rb | 77 +++++++++++++++-- spec/factories/accounts.rb | 5 ++ spec/factories/assignments.rb | 9 ++ spec/factories/drivers.rb | 11 +++ spec/factories/users.rb | 16 ++++ spec/factories/vehicles.rb | 12 +++ spec/jobs/vin_decode_job_spec.rb | 31 +++++++ spec/models/account_spec.rb | 9 ++ spec/models/assignment_spec.rb | 78 +++++++++++++++++ spec/models/driver_spec.rb | 23 +++++ spec/models/user_spec.rb | 19 +++++ spec/models/vehicle_spec.rb | 62 ++++++++++++++ spec/policies/vehicle_policy_spec.rb | 26 ++++++ spec/requests/assignments_spec.rb | 46 ++++++++++ spec/requests/drivers_spec.rb | 30 +++++++ spec/requests/registrations_spec.rb | 44 ++++++++++ spec/requests/vehicles_spec.rb | 83 ++++++++++++++++++ spec/services/vin_decoder_spec.rb | 40 +++++++++ spec/support/authentication_helpers.rb | 21 +++++ spec/support/negated_matchers.rb | 1 + spec/support/pundit_matchers.rb | 20 +++++ spec/system/assigning_drivers_spec.rb | 26 ++++++ spec/system/onboarding_spec.rb | 24 ++++++ 81 files changed, 2171 insertions(+), 25 deletions(-) create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/controllers/assignments_controller.rb create mode 100644 app/controllers/concerns/authentication.rb create mode 100644 app/controllers/drivers_controller.rb create mode 100644 app/controllers/passwords_controller.rb create mode 100644 app/controllers/registrations_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/controllers/vehicles_controller.rb create mode 100644 app/javascript/controllers/autosubmit_controller.js create mode 100644 app/jobs/vin_decode_job.rb create mode 100644 app/mailers/passwords_mailer.rb create mode 100644 app/models/account.rb create mode 100644 app/models/assignment.rb create mode 100644 app/models/concerns/account_scoped.rb create mode 100644 app/models/current.rb create mode 100644 app/models/driver.rb create mode 100644 app/models/registration.rb create mode 100644 app/models/session.rb create mode 100644 app/models/user.rb create mode 100644 app/models/vehicle.rb create mode 100644 app/policies/application_policy.rb create mode 100644 app/policies/assignment_policy.rb create mode 100644 app/policies/driver_policy.rb create mode 100644 app/policies/vehicle_policy.rb create mode 100644 app/services/vin_decoder.rb create mode 100644 app/views/assignments/_form.html.erb create mode 100644 app/views/assignments/edit.html.erb create mode 100644 app/views/assignments/new.html.erb create mode 100644 app/views/drivers/_form.html.erb create mode 100644 app/views/drivers/edit.html.erb create mode 100644 app/views/drivers/index.html.erb create mode 100644 app/views/drivers/new.html.erb create mode 100644 app/views/drivers/show.html.erb create mode 100644 app/views/passwords/edit.html.erb create mode 100644 app/views/passwords/new.html.erb create mode 100644 app/views/passwords_mailer/reset.html.erb create mode 100644 app/views/passwords_mailer/reset.text.erb create mode 100644 app/views/registrations/new.html.erb create mode 100644 app/views/sessions/new.html.erb create mode 100644 app/views/shared/_form_errors.html.erb create mode 100644 app/views/vehicles/_form.html.erb create mode 100644 app/views/vehicles/_status_badge.html.erb create mode 100644 app/views/vehicles/edit.html.erb create mode 100644 app/views/vehicles/index.html.erb create mode 100644 app/views/vehicles/new.html.erb create mode 100644 app/views/vehicles/show.html.erb create mode 100644 db/migrate/20260611225520_create_accounts.rb create mode 100644 db/migrate/20260611225524_create_users.rb create mode 100644 db/migrate/20260611225525_create_sessions.rb create mode 100644 db/migrate/20260611225653_create_vehicles.rb create mode 100644 db/migrate/20260611225654_create_drivers.rb create mode 100644 db/migrate/20260611225655_create_assignments.rb create mode 100644 spec/factories/accounts.rb create mode 100644 spec/factories/assignments.rb create mode 100644 spec/factories/drivers.rb create mode 100644 spec/factories/users.rb create mode 100644 spec/factories/vehicles.rb create mode 100644 spec/jobs/vin_decode_job_spec.rb create mode 100644 spec/models/account_spec.rb create mode 100644 spec/models/assignment_spec.rb create mode 100644 spec/models/driver_spec.rb create mode 100644 spec/models/user_spec.rb create mode 100644 spec/models/vehicle_spec.rb create mode 100644 spec/policies/vehicle_policy_spec.rb create mode 100644 spec/requests/assignments_spec.rb create mode 100644 spec/requests/drivers_spec.rb create mode 100644 spec/requests/registrations_spec.rb create mode 100644 spec/requests/vehicles_spec.rb create mode 100644 spec/services/vin_decoder_spec.rb create mode 100644 spec/support/authentication_helpers.rb create mode 100644 spec/support/negated_matchers.rb create mode 100644 spec/support/pundit_matchers.rb create mode 100644 spec/system/assigning_drivers_spec.rb create mode 100644 spec/system/onboarding_spec.rb 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? %> +
+ + + + + + + + + + + + <% @drivers.each do |driver| %> + + + + + + + + <% end %> + +
NameContactLicenseLicense expiresVehicle
<%= 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 || "—" %>
+
+
<%== @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? %> + + + + + + + + + + <% @assignments.each do |assignment| %> + + + + + + <% end %> + +
VehicleFromTo
<%= 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") %>
+ <% 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? %> +
+ +
+ <% end %> + +
+ <% if notice %> +
<%= notice %>
+ <% end %> + <% if 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? %> +
+ + + + + + + + + + + + + <% @vehicles.each do |vehicle| %> + + + + + + + + + <% end %> + +
VehicleVINPlateStatusOdometerDriver
+ <%= 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 || "—" %>
+
+
<%== @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? %> + + + + + + + + + + + <% @assignments.each do |assignment| %> + + + + + + + <% end %> + +
DriverFromTo
<%= 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 %> +
+ <% else %> +

No drivers have been assigned to this vehicle yet.

+ <% end %> +
+
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/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 From 46a767bc53c18a514eb54956f6c91bc21ac56707 Mon Sep 17 00:00:00 2001 From: faborubio Date: Thu, 11 Jun 2026 23:06:40 -0400 Subject: [PATCH 2/2] Fix bin/dev: run foreman via bundler instead of gem install The stock bin/dev installs foreman as a global gem, which fails on this machine (root-owned rbenv installs to a non-PATH user dir). foreman now lives in the Gemfile and bin/dev invokes it with bundle exec. Logged in docs/POSTMORTEMS.md. Co-Authored-By: Claude Fable 5 --- Gemfile | 3 +++ Gemfile.lock | 4 ++++ bin/dev | 9 +++------ docs/POSTMORTEMS.md | 7 +++++++ 4 files changed, 17 insertions(+), 6 deletions(-) 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/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/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`.