phase002 complete
This commit is contained in:
@@ -9,8 +9,8 @@
|
|||||||
## Commands
|
## Commands
|
||||||
- Setup/update dependencies and DB: `bin/setup --skip-server`. Omit `--skip-server` only when you want it to start `bin/dev` afterward.
|
- Setup/update dependencies and DB: `bin/setup --skip-server`. Omit `--skip-server` only when you want it to start `bin/dev` afterward.
|
||||||
- Run the app: `bin/dev` (it just execs `bin/rails server`).
|
- Run the app: `bin/dev` (it just execs `bin/rails server`).
|
||||||
- Repo CI script: `bin/ci` runs setup, RuboCop, bundler-audit, importmap audit, and strict Brakeman. It does not run specs.
|
- Repo CI script: `bin/ci` runs setup, RSpec, RuboCop, bundler-audit, importmap audit, and strict Brakeman.
|
||||||
- GitHub CI also runs Brakeman, bundler-audit, importmap audit, and RuboCop only; do not assume tests ran in CI.
|
- GitHub CI also runs the repo CI script; do not assume tests ran unless `bin/ci` completed successfully.
|
||||||
- Style check: `bin/rubocop` (`rubocop-rails-omakase` via `.rubocop.yml`).
|
- Style check: `bin/rubocop` (`rubocop-rails-omakase` via `.rubocop.yml`).
|
||||||
- Security checks: `bin/bundler-audit`, `bin/importmap audit`, `bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error`.
|
- Security checks: `bin/bundler-audit`, `bin/importmap audit`, `bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error`.
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ This table will contain information about the users of the app. Mostly used to
|
|||||||
know who updated what. This table will include:
|
know who updated what. This table will include:
|
||||||
|
|
||||||
* *Fields:* `name`, `email`
|
* *Fields:* `name`, `email`
|
||||||
|
* *Required uniqueness:* `email` is unique case-insensitively.
|
||||||
|
|
||||||
|
|
||||||
### 2. Vehicle
|
### 2. Vehicle
|
||||||
@@ -29,7 +30,9 @@ track. They have no strict ownership relationship as they can be used/driven by
|
|||||||
multiple operators. The vehicle information will include:
|
multiple operators. The vehicle information will include:
|
||||||
|
|
||||||
* *Fields:* `make`, `model`, `year`, `color`, `vin`,
|
* *Fields:* `make`, `model`, `year`, `color`, `vin`,
|
||||||
`licence_place`, `current_odometer`, `fuel_tank_size`
|
`licence_plate`, `current_odometer`, `fuel_tank_size`, `active`
|
||||||
|
* *Required uniqueness:* `vin` is unique and normalized to uppercase;
|
||||||
|
`licence_plate` is unique case-insensitively.
|
||||||
|
|
||||||
### 3. MaintenanceSchedule
|
### 3. MaintenanceSchedule
|
||||||
Configures the maintenance rules for a specific vehicle. This allows the
|
Configures the maintenance rules for a specific vehicle. This allows the
|
||||||
@@ -37,6 +40,8 @@ Configures the maintenance rules for a specific vehicle. This allows the
|
|||||||
actual history.
|
actual history.
|
||||||
* *Fields:* `vehicle_id`, `maintenance_type`, `mileage_interval`,
|
* *Fields:* `vehicle_id`, `maintenance_type`, `mileage_interval`,
|
||||||
`time_interval`
|
`time_interval`
|
||||||
|
* *Note:* `time_interval` is stored as integer days. The UI can display the
|
||||||
|
value as months or days later.
|
||||||
* *Relationship:* Belongs to Vehicle
|
* *Relationship:* Belongs to Vehicle
|
||||||
|
|
||||||
### 4. FuelEntry
|
### 4. FuelEntry
|
||||||
@@ -45,12 +50,11 @@ Records specific refueling events.
|
|||||||
`price_paid`, `date`, `updated_by_user_id` (Tracks who submitted)
|
`price_paid`, `date`, `updated_by_user_id` (Tracks who submitted)
|
||||||
* *Relationship:* Belongs to Vehicle
|
* *Relationship:* Belongs to Vehicle
|
||||||
|
|
||||||
### 5. MainetnanceEntry
|
### 5. MaintenanceEntry
|
||||||
Records maintenance and repair history.
|
Records maintenance and repair history.
|
||||||
* *Fields:* `vehicle_id`, `odometer` (at time of service), `description`
|
* *Fields:* `vehicle_id`, `odometer` (at time of service), `name` (Should
|
||||||
(Should exactly match a `maintenance_type` value from the
|
match a `maintenance_type` value from the MaintenanceSchedule), `notes`,
|
||||||
MaintenanceSchedule), `cost`, `date`, `updated_by_user_id` (Tracks who
|
`cost`, `date`, `updated_by_user_id` (Tracks who submitted)
|
||||||
submitted)
|
|
||||||
* *Relationship:* Belongs to Vehicle
|
* *Relationship:* Belongs to Vehicle
|
||||||
|
|
||||||
## Data Relationships
|
## Data Relationships
|
||||||
@@ -71,7 +75,7 @@ Records maintenance and repair history.
|
|||||||
|
|
||||||
### Scenario B: Maintenance
|
### Scenario B: Maintenance
|
||||||
1. User navigates to `Vehicle#show`.
|
1. User navigates to `Vehicle#show`.
|
||||||
2. User fills form with Description, Cost, Date.
|
2. User fills form with Name, Notes, Cost, Date.
|
||||||
3. User submits -> `MaintenanceEntry` is saved to DB.
|
3. User submits -> `MaintenanceEntry` is saved to DB.
|
||||||
|
|
||||||
### Scenario C: Overdue Alerts
|
### Scenario C: Overdue Alerts
|
||||||
@@ -108,7 +112,7 @@ do not need high performance. SQLite should work fantastic in this case.
|
|||||||
|
|
||||||
## Test framework
|
## Test framework
|
||||||
|
|
||||||
The test framework is rspec. You can run the tests with `bin/rails test`
|
The test framework is rspec. You can run the tests with `bundle exec rspec`.
|
||||||
|
|
||||||
## Vehicle ownership
|
## Vehicle ownership
|
||||||
|
|
||||||
@@ -142,6 +146,10 @@ can leave stubs though for that to happen. The notification service just
|
|||||||
requires a curl request so it is simple to add later. For now, just log it into
|
requires a curl request so it is simple to add later. For now, just log it into
|
||||||
the console so that we can test it is working.
|
the console so that we can test it is working.
|
||||||
|
|
||||||
|
Vehicles should not be deleted because maintenance and fuel history must be
|
||||||
|
preserved. If a vehicle should no longer appear in normal workflows, mark it
|
||||||
|
inactive instead.
|
||||||
|
|
||||||
For data access, it would be nice to have 'admin' and 'regular' users however
|
For data access, it would be nice to have 'admin' and 'regular' users however
|
||||||
that is a distinction that we do not yet need. Remember to keep things simple
|
that is a distinction that we do not yet need. Remember to keep things simple
|
||||||
but flexible in the future. Do not wall us in with how code is done.
|
but flexible in the future. Do not wall us in with how code is done.
|
||||||
@@ -151,3 +159,13 @@ user editing the same entry at any time. The only possible issue that I can see
|
|||||||
is if we have two users in the same vehicle and they both submit a fuel
|
is if we have two users in the same vehicle and they both submit a fuel
|
||||||
entry. In this case, we would just delete one of the entries. At no point in
|
entry. In this case, we would just delete one of the entries. At no point in
|
||||||
time should two users be editing the same record.
|
time should two users be editing the same record.
|
||||||
|
|
||||||
|
|
||||||
|
# Platform information
|
||||||
|
|
||||||
|
Note that we are using Ruby 4.0.6 which is still "experimental" with
|
||||||
|
Rails 8. This means that sometimes odd isues can come from gem version
|
||||||
|
issues. For example, I already had to fix an error with the `csrf_meta_tags` in
|
||||||
|
the erb templates. The error was nothing to do with the code but with the JSON
|
||||||
|
gem which Rails requires to be less than version 3 and we had 3.0.2
|
||||||
|
installed. This caused the signatures to be mis-matched and made things wrong.
|
||||||
|
|||||||
@@ -68,3 +68,6 @@ group :development do
|
|||||||
# Use console on exceptions pages [https://github.com/rails/web-console]
|
# Use console on exceptions pages [https://github.com/rails/web-console]
|
||||||
gem "web-console"
|
gem "web-console"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Trying to fix CSRF errors:
|
||||||
|
gem 'json', '< 3'
|
||||||
|
|||||||
+3
-2
@@ -148,7 +148,7 @@ GEM
|
|||||||
jbuilder (2.15.1)
|
jbuilder (2.15.1)
|
||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (3.0.2)
|
json (2.21.2)
|
||||||
kamal (2.12.0)
|
kamal (2.12.0)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
@@ -415,6 +415,7 @@ DEPENDENCIES
|
|||||||
image_processing (~> 1.2)
|
image_processing (~> 1.2)
|
||||||
importmap-rails
|
importmap-rails
|
||||||
jbuilder
|
jbuilder
|
||||||
|
json (< 3)
|
||||||
kamal
|
kamal
|
||||||
propshaft
|
propshaft
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
@@ -487,7 +488,7 @@ CHECKSUMS
|
|||||||
io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
|
io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
|
||||||
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
|
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
|
||||||
jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637
|
jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637
|
||||||
json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df
|
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
|
||||||
kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa
|
kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa
|
||||||
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
|
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
|
||||||
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
class FuelEntry < ApplicationRecord
|
||||||
|
belongs_to :vehicle
|
||||||
|
belongs_to :updated_by_user, class_name: "User"
|
||||||
|
|
||||||
|
validates :odometer, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||||
|
validates :gallons_pumped, presence: true, numericality: { greater_than: 0 }
|
||||||
|
validates :price_paid, presence: true, numericality: { greater_than: 0 }
|
||||||
|
validates :date, presence: true
|
||||||
|
validate :date_cannot_be_in_the_future
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def date_cannot_be_in_the_future
|
||||||
|
return if date.blank? || date <= Date.current
|
||||||
|
|
||||||
|
errors.add(:date, "can't be in the future")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
class MaintenanceEntry < ApplicationRecord
|
||||||
|
belongs_to :vehicle
|
||||||
|
belongs_to :updated_by_user, class_name: "User"
|
||||||
|
|
||||||
|
validates :name, :notes, :date, presence: true
|
||||||
|
validates :odometer, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||||
|
validates :cost, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||||
|
validate :date_cannot_be_in_the_future
|
||||||
|
validate :name_matches_vehicle_schedule
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def date_cannot_be_in_the_future
|
||||||
|
return if date.blank? || date <= Date.current
|
||||||
|
|
||||||
|
errors.add(:date, "can't be in the future")
|
||||||
|
end
|
||||||
|
|
||||||
|
def name_matches_vehicle_schedule
|
||||||
|
return if vehicle.blank? || name.blank?
|
||||||
|
|
||||||
|
scheduled = vehicle.maintenance_schedules.any? do |schedule|
|
||||||
|
schedule.maintenance_type.casecmp?(name)
|
||||||
|
end
|
||||||
|
scheduled ||= vehicle.maintenance_schedules.where("lower(maintenance_type) = ?", name.downcase).exists? if vehicle.persisted?
|
||||||
|
return if scheduled
|
||||||
|
|
||||||
|
errors.add(:name, "must match a maintenance schedule for the vehicle")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class MaintenanceSchedule < ApplicationRecord
|
||||||
|
belongs_to :vehicle
|
||||||
|
|
||||||
|
validates :maintenance_type, presence: true, uniqueness: { scope: :vehicle_id, case_sensitive: false }
|
||||||
|
validates :mileage_interval, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||||
|
validates :time_interval, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
class User < ApplicationRecord
|
||||||
|
has_many :fuel_entries, foreign_key: :updated_by_user_id, dependent: :restrict_with_error, inverse_of: :updated_by_user
|
||||||
|
has_many :maintenance_entries, foreign_key: :updated_by_user_id, dependent: :restrict_with_error, inverse_of: :updated_by_user
|
||||||
|
|
||||||
|
before_validation :normalize_email
|
||||||
|
|
||||||
|
validates :name, presence: true
|
||||||
|
validates :email, presence: true, uniqueness: { case_sensitive: false }
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize_email
|
||||||
|
self.email = email.to_s.strip.downcase if email.present?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
class Vehicle < ApplicationRecord
|
||||||
|
has_many :maintenance_schedules, dependent: :restrict_with_error
|
||||||
|
has_many :fuel_entries, dependent: :restrict_with_error
|
||||||
|
has_many :maintenance_entries, dependent: :restrict_with_error
|
||||||
|
|
||||||
|
before_validation :normalize_vin
|
||||||
|
|
||||||
|
validates :make, :model, :color, :vin, :licence_plate, presence: true
|
||||||
|
validates :vin, uniqueness: true
|
||||||
|
validates :licence_plate, uniqueness: { case_sensitive: false }
|
||||||
|
validates :year, presence: true,
|
||||||
|
numericality: { only_integer: true, greater_than_or_equal_to: 1980, less_than_or_equal_to: ->(_vehicle) { Date.current.year } }
|
||||||
|
validates :current_odometer, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||||
|
validates :fuel_tank_size, presence: true, numericality: { greater_than: 0 }
|
||||||
|
validates :active, inclusion: { in: [ true, false ] }
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize_vin
|
||||||
|
self.vin = vin.to_s.strip.upcase if vin.present?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="application-name" content="Car Tracker">
|
<meta name="application-name" content="Car Tracker">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= csp_meta_tag %>
|
|
||||||
|
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
class CreateUsers < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :users do |t|
|
||||||
|
t.string :name, null: false
|
||||||
|
t.string :email, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :users, "lower(email)", unique: true, name: "index_users_on_lower_email"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class CreateVehicles < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :vehicles do |t|
|
||||||
|
t.string :make, null: false
|
||||||
|
t.string :model, null: false
|
||||||
|
t.integer :year, null: false
|
||||||
|
t.string :color, null: false
|
||||||
|
t.string :vin, null: false
|
||||||
|
t.string :licence_plate, null: false
|
||||||
|
t.integer :current_odometer, null: false
|
||||||
|
t.decimal :fuel_tank_size, precision: 8, scale: 2, null: false
|
||||||
|
t.boolean :active, null: false, default: true
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :vehicles, :vin, unique: true
|
||||||
|
add_index :vehicles, "lower(licence_plate)", unique: true, name: "index_vehicles_on_lower_licence_plate"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
class CreateMaintenanceSchedules < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :maintenance_schedules do |t|
|
||||||
|
t.references :vehicle, null: false, foreign_key: true
|
||||||
|
t.string :maintenance_type, null: false
|
||||||
|
t.integer :mileage_interval, null: false
|
||||||
|
t.integer :time_interval, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :maintenance_schedules,
|
||||||
|
"vehicle_id, lower(maintenance_type)",
|
||||||
|
unique: true,
|
||||||
|
name: "index_maintenance_schedules_on_vehicle_and_lower_type"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
class CreateFuelEntries < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :fuel_entries do |t|
|
||||||
|
t.references :vehicle, null: false, foreign_key: true
|
||||||
|
t.references :updated_by_user, null: false, foreign_key: { to_table: :users }
|
||||||
|
t.integer :odometer, null: false
|
||||||
|
t.decimal :gallons_pumped, precision: 8, scale: 2, null: false
|
||||||
|
t.decimal :price_paid, precision: 10, scale: 2, null: false
|
||||||
|
t.date :date, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
class CreateMaintenanceEntries < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :maintenance_entries do |t|
|
||||||
|
t.references :vehicle, null: false, foreign_key: true
|
||||||
|
t.references :updated_by_user, null: false, foreign_key: { to_table: :users }
|
||||||
|
t.string :name, null: false
|
||||||
|
t.text :notes, null: false
|
||||||
|
t.integer :odometer, null: false
|
||||||
|
t.decimal :cost, precision: 10, scale: 2, null: false
|
||||||
|
t.date :date, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+81
@@ -0,0 +1,81 @@
|
|||||||
|
# This file is auto-generated from the current state of the database. Instead
|
||||||
|
# of editing this file, please use the migrations feature of Active Record to
|
||||||
|
# incrementally modify your database, and then regenerate this schema definition.
|
||||||
|
#
|
||||||
|
# This file is the source Rails uses to define your schema when running `bin/rails
|
||||||
|
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
||||||
|
# be faster and is potentially less error prone than running all of your
|
||||||
|
# migrations from scratch. Old migrations may fail to apply correctly if those
|
||||||
|
# migrations use external dependencies or application code.
|
||||||
|
#
|
||||||
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
|
ActiveRecord::Schema[8.1].define(version: 2026_09_14_132024) do
|
||||||
|
create_table "fuel_entries", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.date "date", null: false
|
||||||
|
t.decimal "gallons_pumped", precision: 8, scale: 2, null: false
|
||||||
|
t.integer "odometer", null: false
|
||||||
|
t.decimal "price_paid", precision: 10, scale: 2, null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "updated_by_user_id", null: false
|
||||||
|
t.integer "vehicle_id", null: false
|
||||||
|
t.index ["updated_by_user_id"], name: "index_fuel_entries_on_updated_by_user_id"
|
||||||
|
t.index ["vehicle_id"], name: "index_fuel_entries_on_vehicle_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "maintenance_entries", force: :cascade do |t|
|
||||||
|
t.decimal "cost", precision: 10, scale: 2, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.date "date", null: false
|
||||||
|
t.string "name", null: false
|
||||||
|
t.text "notes", null: false
|
||||||
|
t.integer "odometer", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "updated_by_user_id", null: false
|
||||||
|
t.integer "vehicle_id", null: false
|
||||||
|
t.index ["updated_by_user_id"], name: "index_maintenance_entries_on_updated_by_user_id"
|
||||||
|
t.index ["vehicle_id"], name: "index_maintenance_entries_on_vehicle_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "maintenance_schedules", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "maintenance_type", null: false
|
||||||
|
t.integer "mileage_interval", null: false
|
||||||
|
t.integer "time_interval", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "vehicle_id", null: false
|
||||||
|
t.index "vehicle_id, lower(maintenance_type)", name: "index_maintenance_schedules_on_vehicle_and_lower_type", unique: true
|
||||||
|
t.index ["vehicle_id"], name: "index_maintenance_schedules_on_vehicle_id"
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "users", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "email", null: false
|
||||||
|
t.string "name", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index "lower(email)", name: "index_users_on_lower_email", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
create_table "vehicles", force: :cascade do |t|
|
||||||
|
t.boolean "active", default: true, null: false
|
||||||
|
t.string "color", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.integer "current_odometer", null: false
|
||||||
|
t.decimal "fuel_tank_size", precision: 8, scale: 2, null: false
|
||||||
|
t.string "licence_plate", null: false
|
||||||
|
t.string "make", null: false
|
||||||
|
t.string "model", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "vin", null: false
|
||||||
|
t.integer "year", null: false
|
||||||
|
t.index "lower(licence_plate)", name: "index_vehicles_on_lower_licence_plate", unique: true
|
||||||
|
t.index ["vin"], name: "index_vehicles_on_vin", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
|
add_foreign_key "fuel_entries", "users", column: "updated_by_user_id"
|
||||||
|
add_foreign_key "fuel_entries", "vehicles"
|
||||||
|
add_foreign_key "maintenance_entries", "users", column: "updated_by_user_id"
|
||||||
|
add_foreign_key "maintenance_entries", "vehicles"
|
||||||
|
add_foreign_key "maintenance_schedules", "vehicles"
|
||||||
|
end
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Phase 002 Execution
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Added the core relational data model for users, vehicles, maintenance schedules, fuel entries, and maintenance entries.
|
||||||
|
- Added validations, factories, and model specs for the new models.
|
||||||
|
- Updated project documentation for confirmed Phase 002 decisions.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- Added migrations for `users`, `vehicles`, `maintenance_schedules`, `fuel_entries`, and `maintenance_entries`.
|
||||||
|
- Added required fields and foreign keys for all Phase 002 tables.
|
||||||
|
- Added unique indexes for user email, vehicle VIN, vehicle licence plate, and per-vehicle maintenance schedule type.
|
||||||
|
- Added `vehicles.active` with default `true`.
|
||||||
|
- Added ActiveRecord models and associations.
|
||||||
|
- Restricted deletion of users and vehicles when historical entries reference them.
|
||||||
|
- Added model validations for presence, uniqueness, numeric bounds, realistic vehicle years, and future-date rejection.
|
||||||
|
- Added VIN normalization to uppercase.
|
||||||
|
- Added maintenance entry validation requiring `name` to match a vehicle maintenance schedule case-insensitively.
|
||||||
|
- Added factories for all new models.
|
||||||
|
- Added model specs for factories, validations, uniqueness, normalization, schedule matching, and restricted deletion.
|
||||||
|
- Updated `AGENTS.md` to reflect that `bin/ci` runs RSpec.
|
||||||
|
- Updated `DESIGN.md` for corrected names and confirmed data-model rules.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
- All Phase 002 table fields are required with `null: false`.
|
||||||
|
- Email and licence plate uniqueness are case-insensitive.
|
||||||
|
- VIN is normalized to uppercase before validation and storage.
|
||||||
|
- `MaintenanceSchedule#time_interval` stores integer days; display conversion can happen later.
|
||||||
|
- `MaintenanceEntry#name` is structured and must match a scheduled maintenance type; `notes` stores free-form details.
|
||||||
|
- Fuel price must be greater than zero; maintenance cost may be zero.
|
||||||
|
- Fuel and maintenance entry dates cannot be in the future.
|
||||||
|
- Vehicles are not deleted; they can be marked inactive later.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Passed: `bundle exec rails db:migrate`
|
||||||
|
- Initial `bundle exec rspec` and `bin/rubocop` failed because `spec/models/maintenance_entry_spec.rb` had an extra `end`.
|
||||||
|
- After fixing the syntax error, `bundle exec rspec` failed because the `maintenance_entry` factory created matching schedules during negative validation specs.
|
||||||
|
- Updated the factory with an `ensure_matching_schedule` transient option and disabled it in negative specs.
|
||||||
|
- Passed: `bundle exec rspec`
|
||||||
|
- Passed: `bin/rubocop`
|
||||||
|
- Passed: `bin/ci`
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
FactoryBot.define do
|
||||||
|
factory :fuel_entry do
|
||||||
|
vehicle
|
||||||
|
association :updated_by_user, factory: :user
|
||||||
|
odometer { 10_500 }
|
||||||
|
gallons_pumped { 18.25 }
|
||||||
|
price_paid { 47.62 }
|
||||||
|
date { Date.current }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
FactoryBot.define do
|
||||||
|
factory :maintenance_entry do
|
||||||
|
vehicle
|
||||||
|
association :updated_by_user, factory: :user
|
||||||
|
name { "Oil Change" }
|
||||||
|
notes { "Oil change performed by Fleet Service Center." }
|
||||||
|
odometer { 10_500 }
|
||||||
|
cost { 89.95 }
|
||||||
|
date { Date.current }
|
||||||
|
|
||||||
|
transient do
|
||||||
|
ensure_matching_schedule { true }
|
||||||
|
end
|
||||||
|
|
||||||
|
after(:build) do |maintenance_entry, evaluator|
|
||||||
|
next unless evaluator.ensure_matching_schedule
|
||||||
|
next if maintenance_entry.vehicle.blank?
|
||||||
|
|
||||||
|
scheduled = maintenance_entry.vehicle.maintenance_schedules.any? do |schedule|
|
||||||
|
schedule.maintenance_type.casecmp?(maintenance_entry.name)
|
||||||
|
end
|
||||||
|
|
||||||
|
next if scheduled
|
||||||
|
|
||||||
|
maintenance_entry.vehicle.maintenance_schedules.build(
|
||||||
|
maintenance_type: maintenance_entry.name,
|
||||||
|
mileage_interval: 5_000,
|
||||||
|
time_interval: 180
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
FactoryBot.define do
|
||||||
|
factory :maintenance_schedule do
|
||||||
|
vehicle
|
||||||
|
maintenance_type { "Oil Change" }
|
||||||
|
mileage_interval { 5_000 }
|
||||||
|
time_interval { 180 }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
FactoryBot.define do
|
||||||
|
factory :user do
|
||||||
|
sequence(:name) { |n| "User #{n}" }
|
||||||
|
sequence(:email) { |n| "user#{n}@example.com" }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
FactoryBot.define do
|
||||||
|
factory :vehicle do
|
||||||
|
make { "Ford" }
|
||||||
|
model { "F-150" }
|
||||||
|
year { Date.current.year }
|
||||||
|
color { "White" }
|
||||||
|
sequence(:vin) { |n| "1FTFW1E#{n.to_s.rjust(10, '0')}" }
|
||||||
|
sequence(:licence_plate) { |n| "FLT#{n.to_s.rjust(4, '0')}" }
|
||||||
|
current_odometer { 10_000 }
|
||||||
|
fuel_tank_size { 26.0 }
|
||||||
|
active { true }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe FuelEntry do
|
||||||
|
it "has a valid factory" do
|
||||||
|
expect(build(:fuel_entry)).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires all fields" do
|
||||||
|
entry = build(
|
||||||
|
:fuel_entry,
|
||||||
|
vehicle: nil,
|
||||||
|
updated_by_user: nil,
|
||||||
|
odometer: nil,
|
||||||
|
gallons_pumped: nil,
|
||||||
|
price_paid: nil,
|
||||||
|
date: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(entry).not_to be_valid
|
||||||
|
expect(entry.errors.attribute_names).to include(:vehicle, :updated_by_user, :odometer, :gallons_pumped, :price_paid, :date)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires valid numeric values" do
|
||||||
|
entry = build(:fuel_entry, odometer: -1, gallons_pumped: 0, price_paid: 0)
|
||||||
|
|
||||||
|
expect(entry).not_to be_valid
|
||||||
|
expect(entry.errors[:odometer]).to be_present
|
||||||
|
expect(entry.errors[:gallons_pumped]).to be_present
|
||||||
|
expect(entry.errors[:price_paid]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects future dates" do
|
||||||
|
entry = build(:fuel_entry, date: Date.current + 1.day)
|
||||||
|
|
||||||
|
expect(entry).not_to be_valid
|
||||||
|
expect(entry.errors[:date]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe MaintenanceEntry do
|
||||||
|
it "has a valid factory" do
|
||||||
|
expect(build(:maintenance_entry)).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires all fields" do
|
||||||
|
entry = build(
|
||||||
|
:maintenance_entry,
|
||||||
|
vehicle: nil,
|
||||||
|
updated_by_user: nil,
|
||||||
|
name: nil,
|
||||||
|
notes: nil,
|
||||||
|
odometer: nil,
|
||||||
|
cost: nil,
|
||||||
|
date: nil,
|
||||||
|
ensure_matching_schedule: false
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(entry).not_to be_valid
|
||||||
|
expect(entry.errors.attribute_names).to include(:vehicle, :updated_by_user, :name, :notes, :odometer, :cost, :date)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires valid numeric values and allows zero cost" do
|
||||||
|
invalid_entry = build(:maintenance_entry, odometer: -1, cost: -1)
|
||||||
|
no_cost_entry = build(:maintenance_entry, cost: 0)
|
||||||
|
|
||||||
|
expect(invalid_entry).not_to be_valid
|
||||||
|
expect(invalid_entry.errors[:odometer]).to be_present
|
||||||
|
expect(invalid_entry.errors[:cost]).to be_present
|
||||||
|
expect(no_cost_entry).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects future dates" do
|
||||||
|
entry = build(:maintenance_entry, date: Date.current + 1.day)
|
||||||
|
|
||||||
|
expect(entry).not_to be_valid
|
||||||
|
expect(entry.errors[:date]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires name to match a vehicle maintenance schedule case-insensitively" do
|
||||||
|
vehicle = create(:vehicle)
|
||||||
|
create(:maintenance_schedule, vehicle: vehicle, maintenance_type: "Oil Change")
|
||||||
|
|
||||||
|
matching_entry = build(:maintenance_entry, vehicle: vehicle, name: "oil change")
|
||||||
|
unknown_entry = build(:maintenance_entry, vehicle: vehicle, name: "Tire Rotation", ensure_matching_schedule: false)
|
||||||
|
|
||||||
|
expect(matching_entry).to be_valid
|
||||||
|
expect(unknown_entry).not_to be_valid
|
||||||
|
expect(unknown_entry.errors[:name]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe MaintenanceSchedule do
|
||||||
|
it "has a valid factory" do
|
||||||
|
expect(build(:maintenance_schedule)).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires all fields" do
|
||||||
|
schedule = build(:maintenance_schedule, vehicle: nil, maintenance_type: nil, mileage_interval: nil, time_interval: nil)
|
||||||
|
|
||||||
|
expect(schedule).not_to be_valid
|
||||||
|
expect(schedule.errors.attribute_names).to include(:vehicle, :maintenance_type, :mileage_interval, :time_interval)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires positive integer intervals" do
|
||||||
|
schedule = build(:maintenance_schedule, mileage_interval: 0, time_interval: 0)
|
||||||
|
|
||||||
|
expect(schedule).not_to be_valid
|
||||||
|
expect(schedule.errors[:mileage_interval]).to be_present
|
||||||
|
expect(schedule.errors[:time_interval]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires maintenance type to be unique per vehicle case-insensitively" do
|
||||||
|
vehicle = create(:vehicle)
|
||||||
|
create(:maintenance_schedule, vehicle: vehicle, maintenance_type: "Oil Change")
|
||||||
|
|
||||||
|
duplicate = build(:maintenance_schedule, vehicle: vehicle, maintenance_type: "oil change")
|
||||||
|
other_vehicle_schedule = build(:maintenance_schedule, maintenance_type: "oil change")
|
||||||
|
|
||||||
|
expect(duplicate).not_to be_valid
|
||||||
|
expect(duplicate.errors[:maintenance_type]).to be_present
|
||||||
|
expect(other_vehicle_schedule).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe User do
|
||||||
|
it "has a valid factory" do
|
||||||
|
expect(build(:user)).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires a name and email" do
|
||||||
|
user = build(:user, name: nil, email: nil)
|
||||||
|
|
||||||
|
expect(user).not_to be_valid
|
||||||
|
expect(user.errors[:name]).to be_present
|
||||||
|
expect(user.errors[:email]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires email to be unique case-insensitively" do
|
||||||
|
create(:user, email: "driver@example.com")
|
||||||
|
|
||||||
|
user = build(:user, email: "DRIVER@example.com")
|
||||||
|
|
||||||
|
expect(user).not_to be_valid
|
||||||
|
expect(user.errors[:email]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "cannot be destroyed when entries reference it" do
|
||||||
|
user = create(:user)
|
||||||
|
create(:fuel_entry, updated_by_user: user)
|
||||||
|
|
||||||
|
expect(user.destroy).to be(false)
|
||||||
|
expect(user.errors[:base]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Vehicle do
|
||||||
|
it "has a valid factory" do
|
||||||
|
expect(build(:vehicle)).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "defaults to active" do
|
||||||
|
expect(described_class.new.active).to be(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires all fields" do
|
||||||
|
vehicle = build(
|
||||||
|
:vehicle,
|
||||||
|
make: nil,
|
||||||
|
model: nil,
|
||||||
|
year: nil,
|
||||||
|
color: nil,
|
||||||
|
vin: nil,
|
||||||
|
licence_plate: nil,
|
||||||
|
current_odometer: nil,
|
||||||
|
fuel_tank_size: nil,
|
||||||
|
active: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(vehicle).not_to be_valid
|
||||||
|
expect(vehicle.errors.attribute_names).to include(
|
||||||
|
:make,
|
||||||
|
:model,
|
||||||
|
:year,
|
||||||
|
:color,
|
||||||
|
:vin,
|
||||||
|
:licence_plate,
|
||||||
|
:current_odometer,
|
||||||
|
:fuel_tank_size,
|
||||||
|
:active
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "normalizes VIN to uppercase" do
|
||||||
|
vehicle = build(:vehicle, vin: "abc123")
|
||||||
|
|
||||||
|
vehicle.valid?
|
||||||
|
|
||||||
|
expect(vehicle.vin).to eq("ABC123")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires VIN to be unique after normalization" do
|
||||||
|
create(:vehicle, vin: "ABC123")
|
||||||
|
|
||||||
|
vehicle = build(:vehicle, vin: "abc123")
|
||||||
|
|
||||||
|
expect(vehicle).not_to be_valid
|
||||||
|
expect(vehicle.errors[:vin]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires licence plate to be unique case-insensitively" do
|
||||||
|
create(:vehicle, licence_plate: "ABC123")
|
||||||
|
|
||||||
|
vehicle = build(:vehicle, licence_plate: "abc123")
|
||||||
|
|
||||||
|
expect(vehicle).not_to be_valid
|
||||||
|
expect(vehicle.errors[:licence_plate]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires a realistic year" do
|
||||||
|
too_old = build(:vehicle, year: 1979)
|
||||||
|
future = build(:vehicle, year: Date.current.year + 1)
|
||||||
|
|
||||||
|
expect(too_old).not_to be_valid
|
||||||
|
expect(future).not_to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires numeric mileage and fuel tank values" do
|
||||||
|
vehicle = build(:vehicle, current_odometer: -1, fuel_tank_size: 0)
|
||||||
|
|
||||||
|
expect(vehicle).not_to be_valid
|
||||||
|
expect(vehicle.errors[:current_odometer]).to be_present
|
||||||
|
expect(vehicle.errors[:fuel_tank_size]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "cannot be destroyed when entries reference it" do
|
||||||
|
vehicle = create(:vehicle)
|
||||||
|
create(:maintenance_schedule, vehicle: vehicle)
|
||||||
|
|
||||||
|
expect(vehicle.destroy).to be(false)
|
||||||
|
expect(vehicle.errors[:base]).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,6 +7,21 @@ RSpec.describe "Home" do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include("Car Tracker")
|
expect(response.body).to include("Car Tracker")
|
||||||
|
expect(response.body).to include("stylesheet")
|
||||||
|
expect(response.body).to include("importmap")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "renders successfully when a content security policy is configured" do
|
||||||
|
original_policy = Rails.application.config.content_security_policy
|
||||||
|
Rails.application.config.content_security_policy do |policy|
|
||||||
|
policy.default_src :self
|
||||||
|
end
|
||||||
|
|
||||||
|
get root_path
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
ensure
|
||||||
|
Rails.application.config.instance_variable_set(:@content_security_policy, original_policy)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user