diff --git a/README.md b/README.md
index f220857..dd3fa5b 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app/controllers/alerts_controller.rb b/app/controllers/alerts_controller.rb
new file mode 100644
index 0000000..8acad52
--- /dev/null
+++ b/app/controllers/alerts_controller.rb
@@ -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
diff --git a/app/controllers/maintenance_schedules_controller.rb b/app/controllers/maintenance_schedules_controller.rb
new file mode 100644
index 0000000..8a4c03b
--- /dev/null
+++ b/app/controllers/maintenance_schedules_controller.rb
@@ -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
diff --git a/app/controllers/renewals_controller.rb b/app/controllers/renewals_controller.rb
new file mode 100644
index 0000000..a106fee
--- /dev/null
+++ b/app/controllers/renewals_controller.rb
@@ -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
diff --git a/app/controllers/service_records_controller.rb b/app/controllers/service_records_controller.rb
new file mode 100644
index 0000000..65c50c6
--- /dev/null
+++ b/app/controllers/service_records_controller.rb
@@ -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
diff --git a/app/controllers/vehicles_controller.rb b/app/controllers/vehicles_controller.rb
index 72502d1..ab76ce4 100644
--- a/app/controllers/vehicles_controller.rb
+++ b/app/controllers/vehicles_controller.rb
@@ -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
diff --git a/app/jobs/account_alerts_job.rb b/app/jobs/account_alerts_job.rb
new file mode 100644
index 0000000..9e39b96
--- /dev/null
+++ b/app/jobs/account_alerts_job.rb
@@ -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
diff --git a/app/jobs/scan_all_alerts_job.rb b/app/jobs/scan_all_alerts_job.rb
new file mode 100644
index 0000000..7dd4f03
--- /dev/null
+++ b/app/jobs/scan_all_alerts_job.rb
@@ -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
diff --git a/app/mailers/alerts_mailer.rb b/app/mailers/alerts_mailer.rb
new file mode 100644
index 0000000..acae33b
--- /dev/null
+++ b/app/mailers/alerts_mailer.rb
@@ -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
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index 3c34c81..cb8826a 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -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
diff --git a/app/models/account.rb b/app/models/account.rb
index 8092744..2eac3c9 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -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
diff --git a/app/models/alert.rb b/app/models/alert.rb
new file mode 100644
index 0000000..fe7305b
--- /dev/null
+++ b/app/models/alert.rb
@@ -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
diff --git a/app/models/driver.rb b/app/models/driver.rb
index 4733abb..e5aa8a3 100644
--- a/app/models/driver.rb
+++ b/app/models/driver.rb
@@ -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
diff --git a/app/models/maintenance_schedule.rb b/app/models/maintenance_schedule.rb
new file mode 100644
index 0000000..7c3b076
--- /dev/null
+++ b/app/models/maintenance_schedule.rb
@@ -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
diff --git a/app/models/renewal.rb b/app/models/renewal.rb
new file mode 100644
index 0000000..d695261
--- /dev/null
+++ b/app/models/renewal.rb
@@ -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
diff --git a/app/models/service_record.rb b/app/models/service_record.rb
new file mode 100644
index 0000000..a0f4bf9
--- /dev/null
+++ b/app/models/service_record.rb
@@ -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
diff --git a/app/models/vehicle.rb b/app/models/vehicle.rb
index 9d715cf..fa0d133 100644
--- a/app/models/vehicle.rb
+++ b/app/models/vehicle.rb
@@ -5,6 +5,10 @@ class Vehicle < ApplicationRecord
has_many :assignments, dependent: :destroy
has_many :drivers, through: :assignments
+ has_many :service_records, dependent: :destroy
+ has_many :maintenance_schedules, dependent: :destroy
+ has_many :renewals, dependent: :destroy
+ has_many :alerts, as: :alertable, dependent: :destroy
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
diff --git a/app/policies/alert_policy.rb b/app/policies/alert_policy.rb
new file mode 100644
index 0000000..d22776c
--- /dev/null
+++ b/app/policies/alert_policy.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AlertPolicy < ApplicationPolicy
+ def destroy?
+ manager_or_admin?
+ end
+end
diff --git a/app/policies/maintenance_schedule_policy.rb b/app/policies/maintenance_schedule_policy.rb
new file mode 100644
index 0000000..b25385e
--- /dev/null
+++ b/app/policies/maintenance_schedule_policy.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class MaintenanceSchedulePolicy < ApplicationPolicy
+end
diff --git a/app/policies/renewal_policy.rb b/app/policies/renewal_policy.rb
new file mode 100644
index 0000000..e67ebef
--- /dev/null
+++ b/app/policies/renewal_policy.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class RenewalPolicy < ApplicationPolicy
+end
diff --git a/app/policies/service_record_policy.rb b/app/policies/service_record_policy.rb
new file mode 100644
index 0000000..9fb7030
--- /dev/null
+++ b/app/policies/service_record_policy.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class ServiceRecordPolicy < ApplicationPolicy
+end
diff --git a/app/services/alerts/dispatcher.rb b/app/services/alerts/dispatcher.rb
new file mode 100644
index 0000000..89ac136
--- /dev/null
+++ b/app/services/alerts/dispatcher.rb
@@ -0,0 +1,32 @@
+module Alerts
+ # Delivers an account's pending alerts: emails a digest to users who opted in,
+ # then marks the alerts as sent so they are not emailed again. In-app delivery
+ # needs no work here — alerts are already queryable from the database.
+ class Dispatcher
+ def self.call(account)
+ new(account).call
+ end
+
+ def initialize(account)
+ @account = account
+ end
+
+ def call
+ pending = @account.alerts.status_pending.by_urgency.to_a
+ return 0 if pending.empty?
+
+ notify_users(pending)
+ Alert.where(id: pending.map(&:id)).update_all(status: :sent, updated_at: Time.current)
+ pending.size
+ end
+
+ private
+
+ def notify_users(alerts)
+ recipients = @account.users.where(notify_by_email: true)
+ recipients.find_each do |user|
+ AlertsMailer.digest(user, alerts.map(&:id)).deliver_later
+ end
+ end
+ end
+end
diff --git a/app/services/alerts/scanner.rb b/app/services/alerts/scanner.rb
new file mode 100644
index 0000000..03b0f6b
--- /dev/null
+++ b/app/services/alerts/scanner.rb
@@ -0,0 +1,100 @@
+module Alerts
+ # Scans one account's vehicles and drivers and reconciles its Alert records:
+ # creates/updates an open alert when a condition holds, dismisses it when it
+ # no longer does. Idempotent — running it repeatedly converges to the same set.
+ class Scanner
+ Desired = Data.define(:category, :severity, :message, :due_on)
+
+ def self.call(account)
+ new(account).call
+ end
+
+ def initialize(account)
+ @account = account
+ end
+
+ # Returns the alerts that are currently open after reconciliation.
+ def call
+ reconcile_renewals
+ reconcile_maintenance
+ reconcile_licenses
+ account.alerts.active
+ end
+
+ private
+
+ attr_reader :account
+
+ def reconcile_renewals
+ account.renewals.includes(:vehicle).find_each do |renewal|
+ desired =
+ if renewal.expired?
+ Desired.new(:renewal_expired, :critical,
+ "#{renewal.kind.humanize} for #{renewal.vehicle.display_name} expired on #{renewal.expires_on.to_fs(:long)}",
+ renewal.expires_on)
+ elsif renewal.due_soon?
+ Desired.new(:renewal_due_soon, :warning,
+ "#{renewal.kind.humanize} for #{renewal.vehicle.display_name} expires on #{renewal.expires_on.to_fs(:long)}",
+ renewal.expires_on)
+ end
+ reconcile(renewal, desired)
+ end
+ end
+
+ def reconcile_maintenance
+ account.maintenance_schedules.includes(:vehicle).find_each do |schedule|
+ desired =
+ if schedule.due?
+ Desired.new(:maintenance_due, :warning,
+ "#{schedule.category.humanize} due for #{schedule.vehicle.display_name}",
+ schedule.next_due_on)
+ elsif schedule.due_soon?
+ Desired.new(:maintenance_due_soon, :info,
+ "#{schedule.category.humanize} coming up for #{schedule.vehicle.display_name}",
+ schedule.next_due_on)
+ end
+ reconcile(schedule, desired)
+ end
+ end
+
+ def reconcile_licenses
+ account.drivers.find_each do |driver|
+ next if driver.license_expires_on.blank?
+
+ desired =
+ if driver.license_expired?
+ Desired.new(:license_expired, :critical,
+ "#{driver.name}'s license expired on #{driver.license_expires_on.to_fs(:long)}",
+ driver.license_expires_on)
+ elsif driver.license_expires_on <= Date.current + Driver::DUE_SOON_DAYS.days
+ Desired.new(:license_due_soon, :warning,
+ "#{driver.name}'s license expires on #{driver.license_expires_on.to_fs(:long)}",
+ driver.license_expires_on)
+ end
+ reconcile(driver, desired)
+ end
+ end
+
+ # Enforces one open alert per (source, category): updates a matching open
+ # alert, creates one if absent, and dismisses any stale open alerts on the
+ # same source whose category no longer applies.
+ def reconcile(source, desired)
+ open_alerts = source.alerts.active.to_a
+
+ if desired
+ alert = open_alerts.find { |a| a.category == desired.category.to_s }
+ if alert
+ alert.update!(severity: desired.severity, message: desired.message, due_on: desired.due_on)
+ else
+ account.alerts.create!(
+ alertable: source, category: desired.category, severity: desired.severity,
+ status: :pending, message: desired.message, due_on: desired.due_on
+ )
+ end
+ open_alerts.reject { |a| a.category == desired.category.to_s }.each(&:dismiss!)
+ else
+ open_alerts.each(&:dismiss!)
+ end
+ end
+ end
+end
diff --git a/app/views/alerts/_alert.html.erb b/app/views/alerts/_alert.html.erb
new file mode 100644
index 0000000..3f0c9ec
--- /dev/null
+++ b/app/views/alerts/_alert.html.erb
@@ -0,0 +1,13 @@
+<% color = { "critical" => "border-red-200 bg-red-50", "warning" => "border-amber-200 bg-amber-50", "info" => "border-slate-200 bg-white" }[alert.severity] %>
+
+
+
+ <%= alert.severity %>
+
+ <%= alert.message %>
+
+ <% if policy(alert).destroy? %>
+ <%= button_to "Dismiss", alert_path(alert), method: :delete,
+ class: "rounded-md border border-slate-300 bg-white px-3 py-1 text-xs hover:bg-slate-100 cursor-pointer" %>
+ <% end %>
+
diff --git a/app/views/alerts/_badge.html.erb b/app/views/alerts/_badge.html.erb
new file mode 100644
index 0000000..a47eb5d
--- /dev/null
+++ b/app/views/alerts/_badge.html.erb
@@ -0,0 +1,5 @@
+
+ <% if count.positive? %>
+ <%= count %>
+ <% end %>
+
diff --git a/app/views/alerts/destroy.turbo_stream.erb b/app/views/alerts/destroy.turbo_stream.erb
new file mode 100644
index 0000000..c1accf9
--- /dev/null
+++ b/app/views/alerts/destroy.turbo_stream.erb
@@ -0,0 +1,4 @@
+<%= turbo_stream.remove dom_id(@alert) if @alert %>
+<%= turbo_stream.replace "alerts_badge" do %>
+ <%= render "alerts/badge", count: current_account.alerts.active.count %>
+<% end %>
diff --git a/app/views/alerts/index.html.erb b/app/views/alerts/index.html.erb
new file mode 100644
index 0000000..9632fed
--- /dev/null
+++ b/app/views/alerts/index.html.erb
@@ -0,0 +1,14 @@
+<% content_for :title, "Alerts — FleetPilot" %>
+
+Alerts
+
+<% if @alerts.any? %>
+
+ <%= render @alerts %>
+
+<% else %>
+
+
You're all caught up
+
No expiring documents, due maintenance or license issues right now.
+
+<% end %>
diff --git a/app/views/alerts_mailer/digest.html.erb b/app/views/alerts_mailer/digest.html.erb
new file mode 100644
index 0000000..f7711e7
--- /dev/null
+++ b/app/views/alerts_mailer/digest.html.erb
@@ -0,0 +1,22 @@
+FleetPilot alerts
+
+
+ Hi <%= @user.email_address %>, the following items in your fleet need attention:
+
+
+
+ <% @alerts.each do |alert| %>
+
+ |
+
+ <%= alert.severity.upcase %>
+
+ <%= alert.message %>
+ |
+
+ <% end %>
+
+
+
+ Review all alerts in FleetPilot →
+
diff --git a/app/views/alerts_mailer/digest.text.erb b/app/views/alerts_mailer/digest.text.erb
new file mode 100644
index 0000000..782e61d
--- /dev/null
+++ b/app/views/alerts_mailer/digest.text.erb
@@ -0,0 +1,10 @@
+FleetPilot alerts
+=================
+
+Hi <%= @user.email_address %>, the following items in your fleet need attention:
+
+<% @alerts.each do |alert| -%>
+- [<%= alert.severity.upcase %>] <%= alert.message %>
+<% end -%>
+
+Review all alerts: <%= alerts_url %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 72ac8eb..83f8d71 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -27,6 +27,9 @@
<%= link_to "FleetPilot", root_path, class: "text-lg font-bold tracking-tight" %>
<%= link_to "Vehicles", vehicles_path, class: nav_link_class(vehicles_path) %>
<%= link_to "Drivers", drivers_path, class: nav_link_class(drivers_path) %>
+ <%= link_to alerts_path, class: nav_link_class(alerts_path) do %>
+ Alerts <%= render "alerts/badge", count: current_account.alerts.active.count %>
+ <% end %>
<%= current_account.name %>
<%= button_to "Sign out", session_path, method: :delete,
diff --git a/app/views/maintenance_schedules/_form.html.erb b/app/views/maintenance_schedules/_form.html.erb
new file mode 100644
index 0000000..27de880
--- /dev/null
+++ b/app/views/maintenance_schedules/_form.html.erb
@@ -0,0 +1,37 @@
+<%= form_with model: maintenance_schedule, url: url, class: "max-w-xl space-y-4" do |form| %>
+ <%= render "shared/form_errors", record: maintenance_schedule %>
+
+
+ <%= form.label :category, class: "block text-sm font-medium" %>
+ <%= form.select :category, MaintenanceSchedule.categories.keys.map { |c| [ c.humanize, c ] },
+ {}, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
+
+ <%= form.label :interval_months, "Every (months)", class: "block text-sm font-medium" %>
+ <%= form.number_field :interval_months, min: 1, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :interval_miles, "Every (miles)", class: "block text-sm font-medium" %>
+ <%= form.number_field :interval_miles, min: 1, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
Set at least one interval. Whichever comes first triggers the next service.
+
+
+
+ <%= form.label :last_performed_on, "Last done on", class: "block text-sm font-medium" %>
+ <%= form.date_field :last_performed_on, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :last_performed_odometer, "Last done at (mi)", class: "block text-sm font-medium" %>
+ <%= form.number_field :last_performed_odometer, min: 0, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
+
+ <%= form.submit "Save", 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", cancel_path, class: "rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100" %>
+
+<% end %>
diff --git a/app/views/maintenance_schedules/edit.html.erb b/app/views/maintenance_schedules/edit.html.erb
new file mode 100644
index 0000000..60293e8
--- /dev/null
+++ b/app/views/maintenance_schedules/edit.html.erb
@@ -0,0 +1,5 @@
+<% content_for :title, "Edit maintenance schedule — FleetPilot" %>
+
+
Edit maintenance schedule — <%= @maintenance_schedule.vehicle.display_name %>
+<%= render "form", maintenance_schedule: @maintenance_schedule,
+ url: maintenance_schedule_path(@maintenance_schedule), cancel_path: @maintenance_schedule.vehicle %>
diff --git a/app/views/maintenance_schedules/new.html.erb b/app/views/maintenance_schedules/new.html.erb
new file mode 100644
index 0000000..5faf442
--- /dev/null
+++ b/app/views/maintenance_schedules/new.html.erb
@@ -0,0 +1,5 @@
+<% content_for :title, "Add maintenance schedule — FleetPilot" %>
+
+
Add maintenance schedule — <%= @vehicle.display_name %>
+<%= render "form", maintenance_schedule: @maintenance_schedule,
+ url: vehicle_maintenance_schedules_path(@vehicle), cancel_path: @vehicle %>
diff --git a/app/views/renewals/_form.html.erb b/app/views/renewals/_form.html.erb
new file mode 100644
index 0000000..e433d94
--- /dev/null
+++ b/app/views/renewals/_form.html.erb
@@ -0,0 +1,24 @@
+<%= form_with model: renewal, url: url, class: "max-w-xl space-y-4" do |form| %>
+ <%= render "shared/form_errors", record: renewal %>
+
+
+
+ <%= form.label :kind, class: "block text-sm font-medium" %>
+ <%= form.select :kind, Renewal.kinds.keys.map { |k| [ k.humanize, k ] },
+ {}, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :expires_on, class: "block text-sm font-medium" %>
+ <%= form.date_field :expires_on, required: true, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
+ <%= form.label :notes, class: "block text-sm font-medium" %>
+ <%= form.text_field :notes, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
+ <%= form.submit "Save", 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", cancel_path, class: "rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100" %>
+
+<% end %>
diff --git a/app/views/renewals/edit.html.erb b/app/views/renewals/edit.html.erb
new file mode 100644
index 0000000..17a0061
--- /dev/null
+++ b/app/views/renewals/edit.html.erb
@@ -0,0 +1,4 @@
+<% content_for :title, "Edit renewal — FleetPilot" %>
+
+
Edit renewal — <%= @renewal.vehicle.display_name %>
+<%= render "form", renewal: @renewal, url: renewal_path(@renewal), cancel_path: @renewal.vehicle %>
diff --git a/app/views/renewals/new.html.erb b/app/views/renewals/new.html.erb
new file mode 100644
index 0000000..1dd72e3
--- /dev/null
+++ b/app/views/renewals/new.html.erb
@@ -0,0 +1,4 @@
+<% content_for :title, "Add renewal — FleetPilot" %>
+
+
Add renewal — <%= @vehicle.display_name %>
+<%= render "form", renewal: @renewal, url: vehicle_renewals_path(@vehicle), cancel_path: @vehicle %>
diff --git a/app/views/service_records/new.html.erb b/app/views/service_records/new.html.erb
new file mode 100644
index 0000000..d1e8b78
--- /dev/null
+++ b/app/views/service_records/new.html.erb
@@ -0,0 +1,40 @@
+<% content_for :title, "Add service record — FleetPilot" %>
+
+
Add service record — <%= @vehicle.display_name %>
+
+<%= form_with model: [ @vehicle, @service_record ], class: "max-w-xl space-y-4" do |form| %>
+ <%= render "shared/form_errors", record: @service_record %>
+
+
+
+ <%= form.label :category, class: "block text-sm font-medium" %>
+ <%= form.select :category, ServiceRecord.categories.keys.map { |c| [ c.humanize, c ] },
+ {}, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :performed_on, class: "block text-sm font-medium" %>
+ <%= form.date_field :performed_on, required: true, 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 :cost_cents, "Cost (cents)", class: "block text-sm font-medium" %>
+ <%= form.number_field :cost_cents, min: 0, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
+ <%= form.label :vendor, class: "block text-sm font-medium" %>
+ <%= form.text_field :vendor, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+ <%= form.label :notes, class: "block text-sm font-medium" %>
+ <%= form.text_area :notes, rows: 3, class: "mt-1 w-full rounded-md border border-slate-300 px-3 py-2" %>
+
+
+
+ <%= form.submit "Save", 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, class: "rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100" %>
+
+<% end %>
diff --git a/app/views/vehicles/_maintenance_schedules.html.erb b/app/views/vehicles/_maintenance_schedules.html.erb
new file mode 100644
index 0000000..0d6d0bd
--- /dev/null
+++ b/app/views/vehicles/_maintenance_schedules.html.erb
@@ -0,0 +1,40 @@
+
+
+
Maintenance schedules
+ <% if policy(MaintenanceSchedule).create? %>
+ <%= link_to "Add schedule", new_vehicle_maintenance_schedule_path(vehicle),
+ class: "rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-700" %>
+ <% end %>
+
+
+ <% if schedules.any? %>
+
+ <% schedules.each do |schedule| %>
+ -
+
+ <%= schedule.category.humanize %>
+
+ every <%= [ schedule.interval_months && "#{schedule.interval_months} mo", schedule.interval_miles && "#{number_with_delimiter(schedule.interval_miles)} mi" ].compact.join(" / ") %>
+
+
+
+ <% if schedule.due? %>
+ Due now
+ <% elsif schedule.due_soon? %>
+ Due soon
+ <% elsif schedule.next_due_on %>
+ Next <%= schedule.next_due_on.to_fs(:long) %>
+ <% else %>
+ No service logged yet
+ <% end %>
+ <% if policy(schedule).update? %>
+ <%= link_to "Edit", edit_maintenance_schedule_path(schedule), class: "text-slate-400 underline hover:text-slate-900" %>
+ <% end %>
+
+
+ <% end %>
+
+ <% else %>
+
No schedules. Add recurring maintenance like oil changes or inspections.
+ <% end %>
+
diff --git a/app/views/vehicles/_renewals.html.erb b/app/views/vehicles/_renewals.html.erb
new file mode 100644
index 0000000..10eb8f9
--- /dev/null
+++ b/app/views/vehicles/_renewals.html.erb
@@ -0,0 +1,33 @@
+
+
+
Renewals
+ <% if policy(Renewal).create? %>
+ <%= link_to "Add renewal", new_vehicle_renewal_path(vehicle),
+ class: "rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-700" %>
+ <% end %>
+
+
+ <% if renewals.any? %>
+
+ <% renewals.each do |renewal| %>
+ -
+ <%= renewal.kind.humanize %>
+
+ <% if renewal.expired? %>
+ Expired <%= renewal.expires_on.to_fs(:long) %>
+ <% elsif renewal.due_soon? %>
+ Due <%= renewal.expires_on.to_fs(:long) %>
+ <% else %>
+ <%= renewal.expires_on.to_fs(:long) %>
+ <% end %>
+ <% if policy(renewal).update? %>
+ <%= link_to "Edit", edit_renewal_path(renewal), class: "text-slate-400 underline hover:text-slate-900" %>
+ <% end %>
+
+
+ <% end %>
+
+ <% else %>
+
No renewals tracked. Add insurance, registration or inspection dates.
+ <% end %>
+
diff --git a/app/views/vehicles/_service_records.html.erb b/app/views/vehicles/_service_records.html.erb
new file mode 100644
index 0000000..30caa19
--- /dev/null
+++ b/app/views/vehicles/_service_records.html.erb
@@ -0,0 +1,44 @@
+
+
+
Service history
+ <% if policy(ServiceRecord).create? %>
+ <%= link_to "Log service", new_vehicle_service_record_path(vehicle),
+ class: "rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-700" %>
+ <% end %>
+
+
+ <% if service_records.any? %>
+
+
+
+ | Date |
+ Category |
+ Vendor |
+ Odometer |
+ Cost |
+ |
+
+
+
+ <% service_records.each do |record| %>
+
+ | <%= record.performed_on.to_fs(:long) %> |
+ <%= record.category.humanize %> |
+ <%= record.vendor || "—" %> |
+ <%= record.odometer ? "#{number_with_delimiter(record.odometer)} mi" : "—" %> |
+ <%= money(record.cost_cents) %> |
+
+ <% if policy(record).destroy? %>
+ <%= button_to "Delete", service_record_path(record), method: :delete,
+ data: { turbo_confirm: "Remove this service record?" },
+ class: "text-slate-400 underline hover:text-red-700 cursor-pointer" %>
+ <% end %>
+ |
+
+ <% end %>
+
+
+ <% else %>
+
No service has been logged for this vehicle yet.
+ <% end %>
+
diff --git a/app/views/vehicles/show.html.erb b/app/views/vehicles/show.html.erb
index b62024f..7457ed5 100644
--- a/app/views/vehicles/show.html.erb
+++ b/app/views/vehicles/show.html.erb
@@ -70,3 +70,10 @@
<% end %>
+
+
+ <%= render "vehicles/renewals", vehicle: @vehicle, renewals: @renewals %>
+ <%= render "vehicles/maintenance_schedules", vehicle: @vehicle, schedules: @maintenance_schedules %>
+
+
+<%= render "vehicles/service_records", vehicle: @vehicle, service_records: @service_records %>
diff --git a/config/recurring.yml b/config/recurring.yml
index b4207f9..5743450 100644
--- a/config/recurring.yml
+++ b/config/recurring.yml
@@ -13,3 +13,6 @@ production:
clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12
+ daily_alert_scan:
+ class: ScanAllAlertsJob
+ schedule: every day at 7am
diff --git a/config/routes.rb b/config/routes.rb
index 9082626..a6a7a91 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -3,9 +3,14 @@
resource :registration, only: %i[new create]
resources :passwords, param: :token
- resources :vehicles
+ resources :vehicles do
+ resources :service_records, only: %i[new create destroy], shallow: true
+ resources :maintenance_schedules, only: %i[new create edit update destroy], shallow: true
+ resources :renewals, only: %i[new create edit update destroy], shallow: true
+ end
resources :drivers
resources :assignments, only: %i[new create edit update destroy]
+ resources :alerts, only: %i[index 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.
diff --git a/db/migrate/20260612031020_create_service_records.rb b/db/migrate/20260612031020_create_service_records.rb
new file mode 100644
index 0000000..386f906
--- /dev/null
+++ b/db/migrate/20260612031020_create_service_records.rb
@@ -0,0 +1,17 @@
+class CreateServiceRecords < ActiveRecord::Migration[8.1]
+ def change
+ create_table :service_records do |t|
+ t.references :account, null: false, foreign_key: true
+ t.references :vehicle, null: false, foreign_key: true
+ t.integer :category, null: false, default: 0
+ t.date :performed_on, null: false
+ t.integer :odometer
+ t.integer :cost_cents
+ t.string :vendor
+ t.text :notes
+
+ t.timestamps
+ end
+ add_index :service_records, [ :vehicle_id, :performed_on ]
+ end
+end
diff --git a/db/migrate/20260612031022_create_maintenance_schedules.rb b/db/migrate/20260612031022_create_maintenance_schedules.rb
new file mode 100644
index 0000000..68c38d5
--- /dev/null
+++ b/db/migrate/20260612031022_create_maintenance_schedules.rb
@@ -0,0 +1,16 @@
+class CreateMaintenanceSchedules < ActiveRecord::Migration[8.1]
+ def change
+ create_table :maintenance_schedules do |t|
+ t.references :account, null: false, foreign_key: true
+ t.references :vehicle, null: false, foreign_key: true
+ t.integer :category, null: false, default: 0
+ t.integer :interval_months
+ t.integer :interval_miles
+ t.date :last_performed_on
+ t.integer :last_performed_odometer
+
+ t.timestamps
+ end
+ add_index :maintenance_schedules, [ :vehicle_id, :category ], unique: true
+ end
+end
diff --git a/db/migrate/20260612031023_create_renewals.rb b/db/migrate/20260612031023_create_renewals.rb
new file mode 100644
index 0000000..945ac8c
--- /dev/null
+++ b/db/migrate/20260612031023_create_renewals.rb
@@ -0,0 +1,15 @@
+class CreateRenewals < ActiveRecord::Migration[8.1]
+ def change
+ create_table :renewals do |t|
+ t.references :account, null: false, foreign_key: true
+ t.references :vehicle, null: false, foreign_key: true
+ t.integer :kind, null: false, default: 0
+ t.date :expires_on, null: false
+ t.string :notes
+
+ t.timestamps
+ end
+ add_index :renewals, [ :vehicle_id, :kind ], unique: true
+ add_index :renewals, :expires_on
+ end
+end
diff --git a/db/migrate/20260612031025_create_alerts.rb b/db/migrate/20260612031025_create_alerts.rb
new file mode 100644
index 0000000..2747d07
--- /dev/null
+++ b/db/migrate/20260612031025_create_alerts.rb
@@ -0,0 +1,20 @@
+class CreateAlerts < ActiveRecord::Migration[8.1]
+ def change
+ create_table :alerts do |t|
+ t.references :account, null: false, foreign_key: true
+ t.references :alertable, polymorphic: true, null: false
+ t.integer :category, null: false, default: 0
+ t.integer :severity, null: false, default: 0
+ t.integer :status, null: false, default: 0
+ t.string :message, null: false
+ t.date :due_on
+
+ t.timestamps
+ end
+ # The scanner enforces "one open alert per source+category" in code; this
+ # index just makes the per-source lookup fast.
+ add_index :alerts, [ :alertable_type, :alertable_id, :category ],
+ name: "index_alerts_on_source_and_category"
+ add_index :alerts, [ :account_id, :status ]
+ end
+end
diff --git a/db/migrate/20260612031732_add_notification_preferences_to_users.rb b/db/migrate/20260612031732_add_notification_preferences_to_users.rb
new file mode 100644
index 0000000..f475634
--- /dev/null
+++ b/db/migrate/20260612031732_add_notification_preferences_to_users.rb
@@ -0,0 +1,6 @@
+class AddNotificationPreferencesToUsers < ActiveRecord::Migration[8.1]
+ def change
+ add_column :users, :notify_by_email, :boolean, null: false, default: true
+ add_column :users, :notify_by_sms, :boolean, null: false, default: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 9a7f350..f3abeaf 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,13 +10,30 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_06_11_225655) do
+ActiveRecord::Schema[8.1].define(version: 2026_06_12_031732) 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 "alerts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.bigint "alertable_id", null: false
+ t.string "alertable_type", null: false
+ t.integer "category", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.date "due_on"
+ t.string "message", null: false
+ t.integer "severity", default: 0, null: false
+ t.integer "status", default: 0, null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "status"], name: "index_alerts_on_account_id_and_status"
+ t.index ["account_id"], name: "index_alerts_on_account_id"
+ t.index ["alertable_type", "alertable_id", "category"], name: "index_alerts_on_source_and_category"
+ t.index ["alertable_type", "alertable_id"], name: "index_alerts_on_alertable"
+ 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
@@ -46,6 +63,51 @@
t.index ["account_id"], name: "index_drivers_on_account_id"
end
+ create_table "maintenance_schedules", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.integer "category", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.integer "interval_miles"
+ t.integer "interval_months"
+ t.integer "last_performed_odometer"
+ t.date "last_performed_on"
+ t.datetime "updated_at", null: false
+ t.bigint "vehicle_id", null: false
+ t.index ["account_id"], name: "index_maintenance_schedules_on_account_id"
+ t.index ["vehicle_id", "category"], name: "index_maintenance_schedules_on_vehicle_id_and_category", unique: true
+ t.index ["vehicle_id"], name: "index_maintenance_schedules_on_vehicle_id"
+ end
+
+ create_table "renewals", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.datetime "created_at", null: false
+ t.date "expires_on", null: false
+ t.integer "kind", default: 0, null: false
+ t.string "notes"
+ t.datetime "updated_at", null: false
+ t.bigint "vehicle_id", null: false
+ t.index ["account_id"], name: "index_renewals_on_account_id"
+ t.index ["expires_on"], name: "index_renewals_on_expires_on"
+ t.index ["vehicle_id", "kind"], name: "index_renewals_on_vehicle_id_and_kind", unique: true
+ t.index ["vehicle_id"], name: "index_renewals_on_vehicle_id"
+ end
+
+ create_table "service_records", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.integer "category", default: 0, null: false
+ t.integer "cost_cents"
+ t.datetime "created_at", null: false
+ t.text "notes"
+ t.integer "odometer"
+ t.date "performed_on", null: false
+ t.datetime "updated_at", null: false
+ t.bigint "vehicle_id", null: false
+ t.string "vendor"
+ t.index ["account_id"], name: "index_service_records_on_account_id"
+ t.index ["vehicle_id", "performed_on"], name: "index_service_records_on_vehicle_id_and_performed_on"
+ t.index ["vehicle_id"], name: "index_service_records_on_vehicle_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"
@@ -59,6 +121,8 @@
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.string "email_address", null: false
+ t.boolean "notify_by_email", default: true, null: false
+ t.boolean "notify_by_sms", default: false, null: false
t.string "password_digest", null: false
t.string "phone"
t.integer "role", default: 0, null: false
@@ -86,10 +150,17 @@
t.index ["account_id"], name: "index_vehicles_on_account_id"
end
+ add_foreign_key "alerts", "accounts"
add_foreign_key "assignments", "accounts"
add_foreign_key "assignments", "drivers"
add_foreign_key "assignments", "vehicles"
add_foreign_key "drivers", "accounts"
+ add_foreign_key "maintenance_schedules", "accounts"
+ add_foreign_key "maintenance_schedules", "vehicles"
+ add_foreign_key "renewals", "accounts"
+ add_foreign_key "renewals", "vehicles"
+ add_foreign_key "service_records", "accounts"
+ add_foreign_key "service_records", "vehicles"
add_foreign_key "sessions", "users"
add_foreign_key "users", "accounts"
add_foreign_key "vehicles", "accounts"
diff --git a/db/seeds.rb b/db/seeds.rb
index 4154ed1..8f956a8 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -64,5 +64,45 @@
end
end
-puts "Seeded: #{Account.count} accounts, #{Vehicle.count} vehicles, #{Driver.count} drivers, #{Assignment.count} assignments"
+if account.renewals.none?
+ account.vehicles.find_each do |vehicle|
+ Renewal.kinds.each_key do |kind|
+ # Mix of valid, expiring-soon and expired dates so the alert engine has work to do.
+ expires = [ rand(60..300).days.from_now, rand(1..25).days.from_now, rand(1..40).days.ago ].sample.to_date
+ vehicle.renewals.create!(account:, kind:, expires_on: expires)
+ end
+ end
+end
+
+if account.maintenance_schedules.none?
+ account.vehicles.find_each do |vehicle|
+ vehicle.maintenance_schedules.create!(
+ account:, category: :oil_change, interval_months: 6, interval_miles: 5_000,
+ last_performed_on: rand(2..8).months.ago.to_date,
+ last_performed_odometer: [ vehicle.odometer - rand(1_000..6_000), 0 ].max
+ )
+ end
+end
+
+if account.service_records.none?
+ account.vehicles.find_each do |vehicle|
+ rand(1..4).times do
+ vehicle.service_records.create!(
+ account:,
+ category: ServiceRecord.categories.keys.sample,
+ performed_on: Faker::Date.between(from: 1.year.ago, to: Date.current),
+ odometer: rand(5_000..vehicle.odometer.clamp(5_000, 200_000)),
+ cost_cents: rand(50..900) * 100,
+ vendor: [ "Quick Lube", "City Tire", "Dealership", "Joe's Garage" ].sample
+ )
+ end
+ end
+end
+
+# Generate the first round of alerts so the demo dashboard isn't empty.
+Alerts::Scanner.call(account)
+
+puts "Seeded: #{Account.count} accounts, #{Vehicle.count} vehicles, #{Driver.count} drivers, " \
+ "#{Assignment.count} assignments, #{Renewal.count} renewals, #{ServiceRecord.count} service records, " \
+ "#{Alert.active.count} open alerts"
puts "Sign in as #{admin.email_address} / password123"
diff --git a/spec/factories/alerts.rb b/spec/factories/alerts.rb
new file mode 100644
index 0000000..06d9dc6
--- /dev/null
+++ b/spec/factories/alerts.rb
@@ -0,0 +1,11 @@
+FactoryBot.define do
+ factory :alert do
+ account
+ alertable { association :renewal, account: account }
+ category { :renewal_expired }
+ severity { :critical }
+ status { :pending }
+ message { "Insurance expired" }
+ due_on { Date.current }
+ end
+end
diff --git a/spec/factories/maintenance_schedules.rb b/spec/factories/maintenance_schedules.rb
new file mode 100644
index 0000000..6913ed1
--- /dev/null
+++ b/spec/factories/maintenance_schedules.rb
@@ -0,0 +1,11 @@
+FactoryBot.define do
+ factory :maintenance_schedule do
+ account
+ vehicle { association :vehicle, account: account }
+ category { :oil_change }
+ interval_months { 6 }
+ interval_miles { 5_000 }
+ last_performed_on { 3.months.ago.to_date }
+ last_performed_odometer { 25_000 }
+ end
+end
diff --git a/spec/factories/renewals.rb b/spec/factories/renewals.rb
new file mode 100644
index 0000000..03183f3
--- /dev/null
+++ b/spec/factories/renewals.rb
@@ -0,0 +1,8 @@
+FactoryBot.define do
+ factory :renewal do
+ account
+ vehicle { association :vehicle, account: account }
+ kind { :insurance }
+ expires_on { 1.year.from_now.to_date }
+ end
+end
diff --git a/spec/factories/service_records.rb b/spec/factories/service_records.rb
new file mode 100644
index 0000000..447da3d
--- /dev/null
+++ b/spec/factories/service_records.rb
@@ -0,0 +1,11 @@
+FactoryBot.define do
+ factory :service_record do
+ account
+ vehicle { association :vehicle, account: account }
+ category { :oil_change }
+ performed_on { Date.current }
+ odometer { 30_000 }
+ cost_cents { 8_900 }
+ vendor { "Quick Lube" }
+ end
+end
diff --git a/spec/fixtures/alerts/digest b/spec/fixtures/alerts/digest
new file mode 100644
index 0000000..a099d5a
--- /dev/null
+++ b/spec/fixtures/alerts/digest
@@ -0,0 +1,3 @@
+Alerts#digest
+
+Hi, find me in app/views/alerts/digest
diff --git a/spec/jobs/alert_jobs_spec.rb b/spec/jobs/alert_jobs_spec.rb
new file mode 100644
index 0000000..8a56ded
--- /dev/null
+++ b/spec/jobs/alert_jobs_spec.rb
@@ -0,0 +1,30 @@
+require 'rails_helper'
+
+RSpec.describe "Alert jobs", type: :job do
+ before { ActiveJob::Base.queue_adapter = :test }
+
+ describe AccountAlertsJob do
+ it "scans for alerts and enqueues the digest email" do
+ account = create(:account)
+ vehicle = create(:vehicle, account:)
+ create(:renewal, account:, vehicle:, expires_on: 2.days.ago.to_date)
+ create(:user, account:, notify_by_email: true)
+
+ expect {
+ described_class.perform_now(account)
+ }.to have_enqueued_mail(AlertsMailer, :digest)
+
+ expect(account.alerts.active.count).to eq(1)
+ end
+ end
+
+ describe ScanAllAlertsJob do
+ it "enqueues one AccountAlertsJob per account" do
+ create_list(:account, 3)
+
+ expect {
+ described_class.perform_now
+ }.to have_enqueued_job(AccountAlertsJob).exactly(3).times
+ end
+ end
+end
diff --git a/spec/mailers/alerts_mailer_spec.rb b/spec/mailers/alerts_mailer_spec.rb
new file mode 100644
index 0000000..065abbb
--- /dev/null
+++ b/spec/mailers/alerts_mailer_spec.rb
@@ -0,0 +1,26 @@
+require "rails_helper"
+
+RSpec.describe AlertsMailer, type: :mailer do
+ describe "#digest" do
+ let(:account) { create(:account) }
+ let(:user) { create(:user, account:) }
+
+ it "lists the alerts and addresses the recipient" do
+ alerts = [
+ create(:alert, account:, severity: :critical, message: "Insurance expired"),
+ create(:alert, account:, severity: :warning, message: "Oil change due", alertable: create(:maintenance_schedule, account:))
+ ]
+
+ mail = described_class.digest(user, alerts.map(&:id))
+
+ expect(mail.to).to eq([ user.email_address ])
+ expect(mail.subject).to include("2 fleet alerts")
+ expect(mail.body.encoded).to include("Insurance expired").and include("Oil change due")
+ end
+
+ it "is not delivered when there are no alerts" do
+ mail = described_class.digest(user, [])
+ expect(mail.to).to be_nil
+ end
+ end
+end
diff --git a/spec/mailers/previews/alerts_mailer_preview.rb b/spec/mailers/previews/alerts_mailer_preview.rb
new file mode 100644
index 0000000..fbda795
--- /dev/null
+++ b/spec/mailers/previews/alerts_mailer_preview.rb
@@ -0,0 +1,9 @@
+# Preview all emails at http://localhost:3000/rails/mailers/alerts_mailer
+class AlertsMailerPreview < ActionMailer::Preview
+ # Preview this email at http://localhost:3000/rails/mailers/alerts_mailer/digest
+ def digest
+ account = Account.first
+ user = account.users.first
+ AlertsMailer.digest(user, account.alerts.active.limit(10).pluck(:id))
+ end
+end
diff --git a/spec/models/alert_spec.rb b/spec/models/alert_spec.rb
new file mode 100644
index 0000000..cc55099
--- /dev/null
+++ b/spec/models/alert_spec.rb
@@ -0,0 +1,26 @@
+require 'rails_helper'
+
+RSpec.describe Alert, type: :model do
+ it { is_expected.to belong_to(:alertable) }
+ it { is_expected.to validate_presence_of(:message) }
+
+ describe "scopes" do
+ it ".active excludes dismissed alerts" do
+ pending_alert = create(:alert, status: :pending)
+ create(:alert, status: :dismissed)
+ expect(Alert.active).to contain_exactly(pending_alert)
+ end
+
+ it ".by_urgency orders critical first, then by due date" do
+ account = create(:account)
+ info = create(:alert, account:, severity: :info, due_on: Date.current)
+ critical = create(:alert, account:, severity: :critical, due_on: 1.week.from_now.to_date)
+ expect(Alert.where(account:).by_urgency.to_a).to eq([ critical, info ])
+ end
+ end
+
+ it "#dismiss! marks the alert dismissed" do
+ alert = create(:alert, status: :pending)
+ expect { alert.dismiss! }.to change(alert, :status).from("pending").to("dismissed")
+ end
+end
diff --git a/spec/models/maintenance_schedule_spec.rb b/spec/models/maintenance_schedule_spec.rb
new file mode 100644
index 0000000..6e06b57
--- /dev/null
+++ b/spec/models/maintenance_schedule_spec.rb
@@ -0,0 +1,75 @@
+require 'rails_helper'
+
+RSpec.describe MaintenanceSchedule, type: :model do
+ it { is_expected.to belong_to(:vehicle) }
+ it { is_expected.to belong_to(:account) }
+
+ it "requires at least one interval" do
+ schedule = build(:maintenance_schedule, interval_months: nil, interval_miles: nil)
+ expect(schedule).not_to be_valid
+ expect(schedule.errors[:base]).to include("set an interval in months, miles, or both")
+ end
+
+ it "is unique per vehicle and category" do
+ existing = create(:maintenance_schedule, category: :oil_change)
+ dup = build(:maintenance_schedule, vehicle: existing.vehicle, account: existing.account, category: :oil_change)
+ expect(dup).not_to be_valid
+ end
+
+ describe "#next_due_on" do
+ it "adds the month interval to the last service date" do
+ schedule = build(:maintenance_schedule, interval_months: 6, last_performed_on: Date.new(2026, 1, 1))
+ expect(schedule.next_due_on).to eq(Date.new(2026, 7, 1))
+ end
+
+ it "is nil without a month interval or a last service date" do
+ expect(build(:maintenance_schedule, interval_months: nil, last_performed_on: Date.current).next_due_on).to be_nil
+ expect(build(:maintenance_schedule, interval_months: 6, last_performed_on: nil).next_due_on).to be_nil
+ end
+ end
+
+ describe "#next_due_odometer" do
+ it "adds the mileage interval to the last service odometer" do
+ schedule = build(:maintenance_schedule, interval_miles: 5_000, last_performed_odometer: 20_000)
+ expect(schedule.next_due_odometer).to eq(25_000)
+ end
+ end
+
+ describe "#due?" do
+ let(:vehicle) { build_stubbed(:vehicle, odometer: 24_000) }
+
+ it "is due when the date threshold has passed" do
+ schedule = build(:maintenance_schedule, vehicle:, interval_months: 6, interval_miles: nil,
+ last_performed_on: 7.months.ago.to_date)
+ expect(schedule.due?).to be(true)
+ end
+
+ it "is due when the mileage threshold has passed" do
+ schedule = build(:maintenance_schedule, vehicle:, interval_months: nil, interval_miles: 5_000,
+ last_performed_odometer: 18_000)
+ expect(schedule.due?(odometer: 24_000)).to be(true)
+ end
+
+ it "is not due when neither threshold has been reached" do
+ schedule = build(:maintenance_schedule, vehicle:, interval_months: 6, interval_miles: 5_000,
+ last_performed_on: 1.month.ago.to_date, last_performed_odometer: 23_000)
+ expect(schedule.due?(odometer: 24_000)).to be(false)
+ end
+ end
+
+ describe "#due_soon?" do
+ let(:vehicle) { build_stubbed(:vehicle, odometer: 24_500) }
+
+ it "is true within the lookahead window but not yet due" do
+ schedule = build(:maintenance_schedule, vehicle:, interval_months: 6, interval_miles: nil,
+ last_performed_on: (6.months.ago + 20.days).to_date)
+ expect(schedule.due_soon?).to be(true)
+ end
+
+ it "is false once actually due" do
+ schedule = build(:maintenance_schedule, vehicle:, interval_months: 6, interval_miles: nil,
+ last_performed_on: 7.months.ago.to_date)
+ expect(schedule.due_soon?).to be(false)
+ end
+ end
+end
diff --git a/spec/models/renewal_spec.rb b/spec/models/renewal_spec.rb
new file mode 100644
index 0000000..94b4ea4
--- /dev/null
+++ b/spec/models/renewal_spec.rb
@@ -0,0 +1,36 @@
+require 'rails_helper'
+
+RSpec.describe Renewal, type: :model do
+ it { is_expected.to belong_to(:vehicle) }
+ it { is_expected.to validate_presence_of(:expires_on) }
+
+ it "is unique per vehicle and kind" do
+ existing = create(:renewal, kind: :insurance)
+ dup = build(:renewal, vehicle: existing.vehicle, account: existing.account, kind: :insurance)
+ expect(dup).not_to be_valid
+ end
+
+ describe "#expired?" do
+ it "is true the day after expiry" do
+ expect(build(:renewal, expires_on: Date.yesterday).expired?).to be(true)
+ end
+
+ it "is false on the expiry day" do
+ expect(build(:renewal, expires_on: Date.current).expired?).to be(false)
+ end
+ end
+
+ describe "#due_soon?" do
+ it "is true within 30 days of expiry" do
+ expect(build(:renewal, expires_on: 10.days.from_now.to_date).due_soon?).to be(true)
+ end
+
+ it "is false when expiry is far away" do
+ expect(build(:renewal, expires_on: 90.days.from_now.to_date).due_soon?).to be(false)
+ end
+
+ it "is false once already expired" do
+ expect(build(:renewal, expires_on: 1.day.ago.to_date).due_soon?).to be(false)
+ end
+ end
+end
diff --git a/spec/models/service_record_spec.rb b/spec/models/service_record_spec.rb
new file mode 100644
index 0000000..a2ab70f
--- /dev/null
+++ b/spec/models/service_record_spec.rb
@@ -0,0 +1,30 @@
+require 'rails_helper'
+
+RSpec.describe ServiceRecord, type: :model do
+ it { is_expected.to belong_to(:vehicle) }
+ it { is_expected.to validate_presence_of(:performed_on) }
+
+ describe "syncing the matching maintenance schedule" do
+ it "updates the schedule's last-performed markers on the same category" do
+ vehicle = create(:vehicle, odometer: 40_000)
+ schedule = create(:maintenance_schedule, vehicle:, account: vehicle.account,
+ category: :oil_change, last_performed_odometer: 20_000)
+
+ create(:service_record, vehicle:, account: vehicle.account,
+ category: :oil_change, performed_on: Date.current, odometer: 40_000)
+
+ expect(schedule.reload.last_performed_odometer).to eq(40_000)
+ expect(schedule.last_performed_on).to eq(Date.current)
+ end
+
+ it "leaves schedules of other categories untouched" do
+ vehicle = create(:vehicle)
+ schedule = create(:maintenance_schedule, vehicle:, account: vehicle.account,
+ category: :tires, last_performed_odometer: 10_000)
+
+ create(:service_record, vehicle:, account: vehicle.account, category: :oil_change, odometer: 50_000)
+
+ expect(schedule.reload.last_performed_odometer).to eq(10_000)
+ end
+ end
+end
diff --git a/spec/requests/alerts_spec.rb b/spec/requests/alerts_spec.rb
new file mode 100644
index 0000000..75beb05
--- /dev/null
+++ b/spec/requests/alerts_spec.rb
@@ -0,0 +1,41 @@
+require 'rails_helper'
+
+RSpec.describe "Alerts", type: :request do
+ let(:user) { create(:user) }
+ let(:account) { user.account }
+
+ before { sign_in user }
+
+ it "lists active alerts only" do
+ create(:alert, account:, message: "Insurance expired", status: :pending)
+ create(:alert, account:, message: "Old dismissed thing", status: :dismissed)
+
+ get alerts_path
+ expect(response.body).to include("Insurance expired")
+ expect(response.body).not_to include("Old dismissed thing")
+ end
+
+ it "dismisses an alert via turbo stream" do
+ alert = create(:alert, account:, status: :pending)
+
+ delete alert_path(alert), headers: { "Accept" => "text/vnd.turbo-stream.html" }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.media_type).to eq("text/vnd.turbo-stream.html")
+ expect(alert.reload.status).to eq("dismissed")
+ end
+
+ it "does not expose another account's alert" do
+ foreign = create(:alert)
+ delete alert_path(foreign)
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it "forbids viewers from dismissing" do
+ sign_in create(:user, :viewer)
+ alert = create(:alert, account: User.last.account, status: :pending)
+
+ delete alert_path(alert)
+ expect(alert.reload.status).to eq("pending")
+ end
+end
diff --git a/spec/requests/maintenance_spec.rb b/spec/requests/maintenance_spec.rb
new file mode 100644
index 0000000..f588846
--- /dev/null
+++ b/spec/requests/maintenance_spec.rb
@@ -0,0 +1,63 @@
+require 'rails_helper'
+
+RSpec.describe "Maintenance resources", type: :request do
+ let(:user) { create(:user) }
+ let(:account) { user.account }
+ let(:vehicle) { create(:vehicle, account:) }
+
+ before { sign_in user }
+
+ describe "service records" do
+ it "logs a service record under a vehicle" do
+ expect {
+ post vehicle_service_records_path(vehicle), params: { service_record: {
+ category: "oil_change", performed_on: Date.current, odometer: 31_000, cost_cents: 9_500
+ } }
+ }.to change(vehicle.service_records, :count).by(1)
+
+ expect(response).to redirect_to(vehicle_path(vehicle))
+ end
+
+ it "cannot attach a record to another account's vehicle" do
+ foreign = create(:vehicle)
+ post vehicle_service_records_path(foreign), params: { service_record: { performed_on: Date.current } }
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe "renewals" do
+ it "adds a renewal" do
+ expect {
+ post vehicle_renewals_path(vehicle), params: { renewal: { kind: "insurance", expires_on: 1.year.from_now.to_date } }
+ }.to change(vehicle.renewals, :count).by(1)
+ end
+ end
+
+ describe "maintenance schedules" do
+ it "adds a schedule" do
+ expect {
+ post vehicle_maintenance_schedules_path(vehicle), params: { maintenance_schedule: {
+ category: "oil_change", interval_months: 6, interval_miles: 5_000
+ } }
+ }.to change(vehicle.maintenance_schedules, :count).by(1)
+ end
+
+ it "rejects a schedule with no interval" do
+ post vehicle_maintenance_schedules_path(vehicle), params: { maintenance_schedule: { category: "brakes" } }
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+
+ describe "authorization" do
+ it "forbids viewers from logging service" do
+ sign_in create(:user, :viewer)
+ own_vehicle = create(:vehicle, account: User.last.account)
+
+ expect {
+ post vehicle_service_records_path(own_vehicle), params: { service_record: { performed_on: Date.current } }
+ }.not_to change(ServiceRecord, :count)
+
+ expect(response).to redirect_to(root_path)
+ end
+ end
+end
diff --git a/spec/services/alerts/dispatcher_spec.rb b/spec/services/alerts/dispatcher_spec.rb
new file mode 100644
index 0000000..8a833e3
--- /dev/null
+++ b/spec/services/alerts/dispatcher_spec.rb
@@ -0,0 +1,34 @@
+require 'rails_helper'
+
+RSpec.describe Alerts::Dispatcher do
+ let(:account) { create(:account) }
+
+ before { ActiveJob::Base.queue_adapter = :test }
+
+ it "emails users who opted in and marks pending alerts as sent" do
+ create(:user, account:, notify_by_email: true)
+ create(:user, account:, notify_by_email: false)
+ alert = create(:alert, account:, status: :pending)
+
+ expect {
+ described_class.call(account)
+ }.to have_enqueued_mail(AlertsMailer, :digest).once
+
+ expect(alert.reload.status).to eq("sent")
+ end
+
+ it "does nothing when there are no pending alerts" do
+ create(:user, account:, notify_by_email: true)
+ create(:alert, account:, status: :sent)
+
+ expect { described_class.call(account) }.not_to have_enqueued_mail(AlertsMailer, :digest)
+ end
+
+ it "does not re-send alerts that were already sent" do
+ create(:user, account:, notify_by_email: true)
+ create(:alert, account:, status: :pending)
+
+ described_class.call(account)
+ expect { described_class.call(account) }.not_to have_enqueued_mail(AlertsMailer, :digest)
+ end
+end
diff --git a/spec/services/alerts/scanner_spec.rb b/spec/services/alerts/scanner_spec.rb
new file mode 100644
index 0000000..5371955
--- /dev/null
+++ b/spec/services/alerts/scanner_spec.rb
@@ -0,0 +1,95 @@
+require 'rails_helper'
+
+RSpec.describe Alerts::Scanner do
+ let(:account) { create(:account) }
+ let(:vehicle) { create(:vehicle, account:, odometer: 50_000) }
+
+ describe "renewals" do
+ it "raises a critical alert for an expired renewal" do
+ renewal = create(:renewal, account:, vehicle:, kind: :insurance, expires_on: 5.days.ago.to_date)
+
+ described_class.call(account)
+
+ alert = renewal.alerts.sole
+ expect(alert).to have_attributes(category: "renewal_expired", severity: "critical", status: "pending")
+ end
+
+ it "raises a warning for a renewal expiring soon" do
+ renewal = create(:renewal, account:, vehicle:, expires_on: 10.days.from_now.to_date)
+ described_class.call(account)
+ expect(renewal.alerts.sole.category).to eq("renewal_due_soon")
+ end
+
+ it "creates no alert for a renewal far in the future" do
+ create(:renewal, account:, vehicle:, expires_on: 1.year.from_now.to_date)
+ described_class.call(account)
+ expect(account.alerts).to be_empty
+ end
+ end
+
+ describe "maintenance" do
+ it "raises a warning when a schedule is due" do
+ schedule = create(:maintenance_schedule, account:, vehicle:, interval_miles: 5_000,
+ interval_months: nil, last_performed_odometer: 44_000)
+ described_class.call(account)
+ expect(schedule.alerts.sole.category).to eq("maintenance_due")
+ end
+ end
+
+ describe "driver licenses" do
+ it "raises a critical alert for an expired license" do
+ driver = create(:driver, account:, license_expires_on: 1.day.ago.to_date)
+ described_class.call(account)
+ expect(driver.alerts.sole).to have_attributes(category: "license_expired", severity: "critical")
+ end
+
+ it "ignores drivers without a license expiry date" do
+ create(:driver, account:, license_expires_on: nil)
+ described_class.call(account)
+ expect(account.alerts).to be_empty
+ end
+ end
+
+ describe "idempotency and reconciliation" do
+ it "does not duplicate alerts across runs" do
+ create(:renewal, account:, vehicle:, expires_on: 5.days.ago.to_date)
+ expect { 2.times { described_class.call(account) } }.to change { account.alerts.count }.by(1)
+ end
+
+ it "updates the message in place when the underlying date changes" do
+ renewal = create(:renewal, account:, vehicle:, expires_on: 5.days.ago.to_date)
+ described_class.call(account)
+ original = renewal.alerts.sole
+
+ renewal.update!(expires_on: 10.days.ago.to_date)
+ described_class.call(account)
+
+ updated = renewal.alerts.active.sole
+ expect(updated.id).to eq(original.id)
+ expect(updated.message).to include(10.days.ago.to_date.to_fs(:long))
+ expect(updated.message).not_to eq(original.message)
+ end
+
+ it "dismisses the alert once the condition clears" do
+ renewal = create(:renewal, account:, vehicle:, expires_on: 5.days.ago.to_date)
+ described_class.call(account)
+
+ renewal.update!(expires_on: 1.year.from_now.to_date)
+ described_class.call(account)
+
+ expect(renewal.alerts.active).to be_empty
+ expect(renewal.alerts.status_dismissed).to be_present
+ end
+
+ it "swaps due_soon for expired as time passes" do
+ renewal = create(:renewal, account:, vehicle:, expires_on: 10.days.from_now.to_date)
+ described_class.call(account)
+ expect(renewal.alerts.active.sole.category).to eq("renewal_due_soon")
+
+ renewal.update!(expires_on: 1.day.ago.to_date)
+ described_class.call(account)
+
+ expect(renewal.alerts.active.sole.category).to eq("renewal_expired")
+ end
+ end
+end