Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ that keeps it from happening again.

- [x] Phase 0 — Foundations: tooling, test suite, CI, security scanning
- [x] Phase 1 — Fleet core: accounts, auth, vehicles (VIN decoding), drivers, assignments
- [ ] Phase 2 — Maintenance: service records, schedules, renewals, alert engine
- [x] 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
- [ ] Phase 5 — Deployment: Kamal to GCP, live demo
17 changes: 17 additions & 0 deletions app/controllers/alerts_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class AlertsController < ApplicationController
def index
authorize Alert
@alerts = current_account.alerts.active.by_urgency.includes(:alertable)
end

def destroy
@alert = current_account.alerts.find(params[:id])
authorize @alert
@alert.dismiss!

respond_to do |format|
format.turbo_stream
format.html { redirect_to alerts_path, notice: "Alert dismissed." }
end
end
end
53 changes: 53 additions & 0 deletions app/controllers/maintenance_schedules_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
class MaintenanceSchedulesController < ApplicationController
before_action :set_vehicle, only: %i[new create]
before_action :set_schedule, only: %i[edit update destroy]

def new
@maintenance_schedule = @vehicle.maintenance_schedules.new
authorize @maintenance_schedule
end

def create
@maintenance_schedule = @vehicle.maintenance_schedules.new(schedule_params.merge(account: current_account))
authorize @maintenance_schedule

if @maintenance_schedule.save
redirect_to @vehicle, notice: "Maintenance schedule added."
else
render :new, status: :unprocessable_entity
end
end

def edit
end

def update
if @maintenance_schedule.update(schedule_params)
redirect_to @maintenance_schedule.vehicle, notice: "Maintenance schedule updated."
else
render :edit, status: :unprocessable_entity
end
end

def destroy
vehicle = @maintenance_schedule.vehicle
@maintenance_schedule.destroy!
redirect_to vehicle, notice: "Maintenance schedule removed."
end

private

def set_vehicle
@vehicle = current_account.vehicles.find(params[:vehicle_id])
end

def set_schedule
@maintenance_schedule = current_account.maintenance_schedules.find(params[:id])
authorize @maintenance_schedule
end

def schedule_params
params.expect(maintenance_schedule: [ :category, :interval_months, :interval_miles,
:last_performed_on, :last_performed_odometer ])
end
end
52 changes: 52 additions & 0 deletions app/controllers/renewals_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
class RenewalsController < ApplicationController
before_action :set_vehicle, only: %i[new create]
before_action :set_renewal, only: %i[edit update destroy]

def new
@renewal = @vehicle.renewals.new
authorize @renewal
end

def create
@renewal = @vehicle.renewals.new(renewal_params.merge(account: current_account))
authorize @renewal

if @renewal.save
redirect_to @vehicle, notice: "Renewal added."
else
render :new, status: :unprocessable_entity
end
end

def edit
end

def update
if @renewal.update(renewal_params)
redirect_to @renewal.vehicle, notice: "Renewal updated."
else
render :edit, status: :unprocessable_entity
end
end

def destroy
vehicle = @renewal.vehicle
@renewal.destroy!
redirect_to vehicle, notice: "Renewal removed."
end

private

def set_vehicle
@vehicle = current_account.vehicles.find(params[:vehicle_id])
end

def set_renewal
@renewal = current_account.renewals.find(params[:id])
authorize @renewal
end

def renewal_params
params.expect(renewal: [ :kind, :expires_on, :notes ])
end
end
37 changes: 37 additions & 0 deletions app/controllers/service_records_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class ServiceRecordsController < ApplicationController
before_action :set_vehicle, only: %i[new create]

def new
@service_record = @vehicle.service_records.new(performed_on: Date.current, odometer: @vehicle.odometer)
authorize @service_record
end

def create
@service_record = @vehicle.service_records.new(service_record_params.merge(account: current_account))
authorize @service_record

if @service_record.save
redirect_to @vehicle, notice: "Service record added."
else
render :new, status: :unprocessable_entity
end
end

def destroy
@service_record = current_account.service_records.find(params[:id])
authorize @service_record
vehicle = @service_record.vehicle
@service_record.destroy!
redirect_to vehicle, notice: "Service record removed."
end

private

def set_vehicle
@vehicle = current_account.vehicles.find(params[:vehicle_id])
end

def service_record_params
params.expect(service_record: [ :category, :performed_on, :odometer, :cost_cents, :vendor, :notes ])
end
end
3 changes: 3 additions & 0 deletions app/controllers/vehicles_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ def index

def show
@assignments = @vehicle.assignments.includes(:driver).order(started_on: :desc)
@service_records = @vehicle.service_records.recent_first.limit(20)
@maintenance_schedules = @vehicle.maintenance_schedules.order(:category)
@renewals = @vehicle.renewals.order(:expires_on)
end

def new
Expand Down
9 changes: 9 additions & 0 deletions app/jobs/account_alerts_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Reconciles and dispatches alerts for a single account.
class AccountAlertsJob < ApplicationJob
queue_as :default

def perform(account)
Alerts::Scanner.call(account)
Alerts::Dispatcher.call(account)
end
end
11 changes: 11 additions & 0 deletions app/jobs/scan_all_alerts_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Fans out the daily alert scan: one AccountAlertsJob per account, so a slow or
# failing account does not block the others.
class ScanAllAlertsJob < ApplicationJob
queue_as :default

def perform
Account.find_each do |account|
AccountAlertsJob.perform_later(account)
end
end
end
18 changes: 18 additions & 0 deletions app/mailers/alerts_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
class AlertsMailer < ApplicationMailer
# Sends one user a digest of the given alerts. Takes alert IDs (not records)
# so the mail is safe to enqueue and deliver later.
def digest(user, alert_ids)
@user = user
@alerts = user.account.alerts.where(id: alert_ids).by_urgency
return if @alerts.empty?

mail to: user.email_address,
subject: "FleetPilot: #{pluralize(@alerts.size, 'fleet alert')} need your attention"
end

private

def pluralize(count, noun)
ActionController::Base.helpers.pluralize(count, noun)
end
end
2 changes: 1 addition & 1 deletion app/mailers/application_mailer.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
default from: ENV.fetch("MAIL_FROM", "alerts@fleetpilot.app")
layout "mailer"
end
4 changes: 4 additions & 0 deletions app/models/account.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ class Account < ApplicationRecord
has_many :vehicles, dependent: :destroy
has_many :drivers, dependent: :destroy
has_many :assignments, dependent: :destroy
has_many :service_records, dependent: :destroy
has_many :maintenance_schedules, dependent: :destroy
has_many :renewals, dependent: :destroy
has_many :alerts, dependent: :destroy

validates :name, presence: true
end
24 changes: 24 additions & 0 deletions app/models/alert.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
class Alert < ApplicationRecord
include AccountScoped

belongs_to :alertable, polymorphic: true

enum :category, {
renewal_expired: 0, renewal_due_soon: 1,
maintenance_due: 2, maintenance_due_soon: 3,
license_expired: 4, license_due_soon: 5
}, prefix: true

enum :severity, { info: 0, warning: 1, critical: 2 }, prefix: true
enum :status, { pending: 0, sent: 1, dismissed: 2 }, prefix: true

validates :message, presence: true

scope :open, -> { where.not(status: :dismissed) }
scope :active, -> { where(status: %i[pending sent]) }
scope :by_urgency, -> { order(severity: :desc, due_on: :asc) }

def dismiss!
update!(status: :dismissed)
end
end
3 changes: 3 additions & 0 deletions app/models/driver.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
class Driver < ApplicationRecord
DUE_SOON_DAYS = 30

include AccountScoped

has_many :assignments, dependent: :destroy
has_many :vehicles, through: :assignments
has_many :alerts, as: :alertable, dependent: :destroy

enum :status, { active: 0, inactive: 1 }, default: :active

Expand Down
63 changes: 63 additions & 0 deletions app/models/maintenance_schedule.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
class MaintenanceSchedule < ApplicationRecord
include AccountScoped

belongs_to :vehicle
has_many :alerts, as: :alertable, dependent: :destroy

# Soon to be due: surfaces upcoming maintenance before it is overdue.
DUE_SOON_DAYS = 30
DUE_SOON_MILES = 1_000

enum :category, ServiceRecord::CATEGORIES, default: :oil_change

validates :category, uniqueness: { scope: :vehicle_id }
validates :interval_months, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validates :interval_miles, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
validate :at_least_one_interval

def record_service(performed_on:, odometer:)
self.last_performed_on = performed_on if performed_on
self.last_performed_odometer = odometer if odometer
save!
end

def next_due_on
return nil if interval_months.blank? || last_performed_on.blank?

last_performed_on + interval_months.months
end

def next_due_odometer
return nil if interval_miles.blank? || last_performed_odometer.blank?

last_performed_odometer + interval_miles
end

# Due when either the time-based or mileage-based threshold has passed.
def due?(as_of: Date.current, odometer: vehicle.odometer)
due_by_date?(as_of) || due_by_mileage?(odometer)
end

def due_soon?(as_of: Date.current, odometer: vehicle.odometer)
return false if due?(as_of:, odometer:)

(next_due_on.present? && next_due_on <= as_of + DUE_SOON_DAYS.days) ||
(next_due_odometer.present? && next_due_odometer <= odometer + DUE_SOON_MILES)
end

private

def due_by_date?(as_of)
next_due_on.present? && next_due_on <= as_of
end

def due_by_mileage?(odometer)
next_due_odometer.present? && odometer.present? && odometer >= next_due_odometer
end

def at_least_one_interval
if interval_months.blank? && interval_miles.blank?
errors.add(:base, "set an interval in months, miles, or both")
end
end
end
23 changes: 23 additions & 0 deletions app/models/renewal.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
class Renewal < ApplicationRecord
include AccountScoped

belongs_to :vehicle
has_many :alerts, as: :alertable, dependent: :destroy

DUE_SOON_DAYS = 30

enum :kind, { insurance: 0, registration: 1, inspection: 2 }, default: :insurance

validates :kind, uniqueness: { scope: :vehicle_id }
validates :expires_on, presence: true

scope :expiring_before, ->(date) { where(expires_on: ..date) }

def expired?(as_of: Date.current)
expires_on < as_of
end

def due_soon?(as_of: Date.current)
!expired?(as_of:) && expires_on <= as_of + DUE_SOON_DAYS.days
end
end
25 changes: 25 additions & 0 deletions app/models/service_record.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
class ServiceRecord < ApplicationRecord
include AccountScoped

belongs_to :vehicle

CATEGORIES = { oil_change: 0, tires: 1, brakes: 2, inspection: 3, repair: 4, other: 5 }.freeze
enum :category, CATEGORIES, default: :other

validates :performed_on, presence: true
validates :odometer, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
validates :cost_cents, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true

after_commit :refresh_matching_schedule, on: %i[create update]

scope :recent_first, -> { order(performed_on: :desc) }

private

# Keep the matching maintenance schedule's "last performed" markers in sync
# so the next-due calculation reflects the latest service.
def refresh_matching_schedule
schedule = vehicle.maintenance_schedules.find_by(category: category)
schedule&.record_service(performed_on:, odometer:)
end
end
Loading
Loading