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? %> + +<% 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| %> + + + + <% end %> +
    + + <%= alert.severity.upcase %> + + <%= alert.message %> +
    + +

    + 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 %>
    <%= 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? %> + + <% 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? %> + + <% 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? %> + + + + + + + + + + + + + <% service_records.each do |record| %> + + + + + + + + + <% end %> + +
    DateCategoryVendorOdometerCost
    <%= 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 %> +
    + <% 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