Tuesday, October 7, 2025

Building a Property Inspection System: Data Model to Mobile Forms

Paul (Founder)
Development

Property inspections are foundational to letting agency operations. Staff conduct routine inspections during tenancies, inventory checks at move-in and move-out, compliance visits verifying safety regulations, and maintenance inspections assessing repair needs. Each inspection requires documenting property condition with photos, structured observations, and often signatures from tenants or landlords.

Week 41 began building LetAdmin's inspection system from the ground up: defining the data model to capture inspection details, implementing CRUD operations for managing inspections, designing mobile-optimized forms for on-site data capture, and establishing the foundation for photo uploads and digital signatures. This article explores how we structured inspections to handle diverse inspection types whilst maintaining simplicity and mobile usability.

What Your Team Will Notice

The inspections section appears in the main navigation, providing dedicated space for managing all property inspections. The index page lists inspections chronologically with clear status indicators: Scheduled (upcoming inspections), In Progress (partially completed), Completed (finished and signed), and Draft (created but not yet conducted).

Creating a new inspection starts with property selection—staff choose which property needs inspecting. The inspection type field offers common scenarios: Routine Inspection, Inventory Check, Move-In Inspection, Move-Out Inspection, Compliance Visit, Maintenance Assessment. Each type suggests appropriate inspection focus without rigidly constraining what can be documented.

The inspection form organizes data capture logically: basic details (date, time, inspector name) at the top, then room-by-room observations allowing detailed notes about each space, a general observations section for overall property comments, and sections for photos and signatures. This structure mirrors how staff naturally conduct inspections—walking through the property room by room, noting specific issues, then capturing overall impressions.

Mobile optimization ensures forms work well on phones and tablets staff carry on-site. Input fields are appropriately sized for touch interaction, form sections collapse to avoid excessive scrolling, and the layout adapts to portrait and landscape orientations. Staff can conduct complete inspections using only their mobile devices without requiring laptops or clipboards.

Under the Bonnet: Inspection Data Model

The inspection model captures structured data about property visits:

class Inspection < ApplicationRecord
  belongs_to :property
  belongs_to :agency
  belongs_to :conducted_by, class_name: 'User', optional: true
  acts_as_tenant :agency

  has_many :inspection_photos, dependent: :destroy
  has_one :inspection_signature, dependent: :destroy

  # Inspection types
  enum inspection_type: {
    routine: 'routine',
    inventory: 'inventory',
    move_in: 'move_in',
    move_out: 'move_out',
    compliance: 'compliance',
    maintenance: 'maintenance'
  }

  # Inspection status
  enum status: {
    draft: 'draft',
    scheduled: 'scheduled',
    in_progress: 'in_progress',
    completed: 'completed'
  }

  validates :property, presence: true
  validates :inspection_type, presence: true
  validates :inspection_date, presence: true
  validates :status, presence: true

  # Structured observations stored as JSON
  # Format: { "living_room": "notes...", "kitchen": "notes...", ... }
  attribute :room_observations, :json, default: {}
  attribute :general_observations, :text

  scope :recent, -> { order(inspection_date: :desc, created_at: :desc) }
  scope :upcoming, -> { where(status: [:scheduled, :in_progress]).where('inspection_date >= ?', Date.today) }
  scope :past, -> { where(status: :completed).or(where('inspection_date < ?', Date.today)) }

  def completed?
    status == 'completed'
  end

  def has_signature?
    inspection_signature.present?
  end
end

This design balances flexibility with structure. Room observations use JSON allowing agencies to define room names matching their properties (flats might have "living room/kitchen", houses separate "living room" and "kitchen"). General observations remain unstructured text accommodating varied note-taking styles.

Inspection Photo Association

Photos attach to inspections through a join model:

class InspectionPhoto < ApplicationRecord
  belongs_to :inspection
  belongs_to :agency
  acts_as_tenant :agency

  # Active Storage attachment
  has_one_attached :image

  validates :inspection, presence: true
  validates :image, presence: true

  # Optional room association
  attribute :room_name, :string
  attribute :description, :text
  attribute :display_order, :integer, default: 0

  scope :ordered, -> { order(display_order: :asc, created_at: :asc) }
  scope :for_room, ->(room) { where(room_name: room) }

  def image_url
    return nil unless image.attached?

    Rails.application.routes.url_helpers.rails_blob_url(image, only_path: true)
  end
end

Photos can optionally associate with specific rooms, enabling room-by-room photo organization in inspection reports. Display order allows manual sorting when photo sequence matters for documenting issues.

CRUD Operations and Controllers

Standard resourceful routes provide complete inspection management:

# config/routes.rb
resources :inspections do
  member do
    post :add_photo
    delete :remove_photo
    post :add_signature
    patch :complete
  end

  collection do
    get :upcoming
    get :past
  end
end

The controller implements typical CRUD with mobile-friendly considerations:

class InspectionsController < ApplicationController
  before_action :set_inspection, only: [:show, :edit, :update, :destroy, :complete]

  def index
    @inspections = Inspection.includes(:property, :conducted_by)
                            .recent
                            .page(params[:page])
                            .per(25)
  end

  def show
    @photos = @inspection.inspection_photos.ordered
    @signature = @inspection.inspection_signature
  end

  def new
    @inspection = Inspection.new(
      inspection_date: Date.today,
      status: 'draft',
      conducted_by: current_user
    )
    @properties = Property.where(agency: current_tenant).order(:reference)
  end

  def create
    @inspection = Inspection.new(inspection_params)
    @inspection.agency = current_tenant
    @inspection.conducted_by = current_user

    if @inspection.save
      redirect_to edit_inspection_path(@inspection),
                 notice: 'Inspection created. Add observations and photos.'
    else
      @properties = Property.where(agency: current_tenant).order(:reference)
      render :new, status: :unprocessable_entity
    end
  end

  def edit
    # Mobile-optimized edit view for conducting inspections on-site
    @photos = @inspection.inspection_photos.ordered
    @rooms = extract_rooms_from_observations
  end

  def update
    if @inspection.update(inspection_params)
      # Stay on edit page for continued data entry during inspection
      redirect_to edit_inspection_path(@inspection),
                 notice: 'Inspection updated'
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def complete
    @inspection.status = 'completed'
    @inspection.completed_at = Time.current

    if @inspection.save
      redirect_to @inspection, notice: 'Inspection completed'
    else
      redirect_to edit_inspection_path(@inspection),
                 alert: 'Cannot complete: ' + @inspection.errors.full_messages.join(', ')
    end
  end

  private

  def set_inspection
    @inspection = Inspection.find(params[:id])
  end

  def inspection_params
    params.require(:inspection).permit(
      :property_id, :inspection_type, :inspection_date, :inspection_time,
      :status, :general_observations, room_observations: {}
    )
  end

  def extract_rooms_from_observations
    return [] unless @inspection.room_observations.present?

    @inspection.room_observations.keys.sort
  end
end

The edit action serves as the primary interface for conducting inspections—staff stay on this page whilst walking through properties, adding observations, uploading photos, and eventually completing the inspection.

Mobile-Optimized Forms

The inspection form adapts to mobile devices through responsive design and thoughtful UX:

<%= form_with(model: @inspection, local: true) do |form| %>
  <!-- Basic Details Section -->
  <div class="form-section">
    <h3>Inspection Details</h3>

    <div class="form-group">
      <%= form.label :property_id, "Property" %>
      <%= form.collection_select :property_id, @properties, :id, :display_name,
                                { prompt: 'Select property...' },
                                class: 'form-select',
                                disabled: @inspection.persisted? %>
    </div>

    <div class="form-row-mobile">
      <div class="form-group">
        <%= form.label :inspection_type %>
        <%= form.select :inspection_type,
                       options_for_select(Inspection.inspection_types.keys.map { |k|
                         [k.titleize, k]
                       }, @inspection.inspection_type),
                       {},
                       class: 'form-select' %>
      </div>

      <div class="form-group">
        <%= form.label :inspection_date, "Date" %>
        <%= form.date_field :inspection_date, class: 'form-control' %>
      </div>
    </div>
  </div>

  <!-- Room-by-Room Observations -->
  <div class="form-section" x-data="roomObservations()">
    <h3>Room Observations</h3>

    <template x-for="(room, index) in rooms" :key="index">
      <div class="room-observation-card">
        <div class="room-header">
          <input type="text"
                 x-model="room.name"
                 placeholder="Room name (e.g., Living Room)"
                 class="room-name-input" />
          <button type="button"
                  @click="removeRoom(index)"
                  x-show="rooms.length > 1"
                  class="btn-remove-room">
            Remove
          </button>
        </div>

        <textarea x-model="room.observations"
                  :name="'inspection[room_observations][' + room.name + ']'"
                  rows="4"
                  placeholder="Observations for this room..."
                  class="form-textarea"></textarea>
      </div>
    </template>

    <button type="button"
            @click="addRoom()"
            class="btn btn-secondary">
      Add Room
    </button>
  </div>

  <!-- General Observations -->
  <div class="form-section">
    <h3>General Observations</h3>
    <%= form.text_area :general_observations,
                      rows: 6,
                      placeholder: 'Overall property condition, general notes...',
                      class: 'form-textarea' %>
  </div>

  <!-- Save Buttons -->
  <div class="form-actions-mobile">
    <%= form.submit 'Save Progress', class: 'btn btn-secondary' %>

    <% if @inspection.persisted? %>
      <%= button_to 'Complete Inspection',
                   complete_inspection_path(@inspection),
                   method: :patch,
                   class: 'btn btn-primary',
                   data: { confirm: 'Mark this inspection as completed?' } %>
    <% end %>
  </div>
<% end %>

<script>
// Alpine.js component for dynamic room management
function roomObservations() {
  return {
    rooms: <%= raw (@inspection.room_observations.presence || {
      'living_room' => ''
    }).map { |name, obs| { name: name, observations: obs } }.to_json %>,

    addRoom() {
      this.rooms.push({ name: '', observations: '' });
    },

    removeRoom(index) {
      this.rooms.splice(index, 1);
    }
  };
}
</script>

This form provides several mobile-friendly features: large touch targets for buttons and inputs, collapsible sections reducing scroll distance, dynamic room addition without page reloads, and clear visual separation between form sections.

Testing Inspection Workflows

Comprehensive tests verify inspection functionality:

RSpec.describe Inspection, type: :model do
  describe "validations" do
    it "requires property, type, date, and status" do
      inspection = build(:inspection,
                        property: nil,
                        inspection_type: nil,
                        inspection_date: nil)

      expect(inspection).not_to be_valid
      expect(inspection.errors[:property]).to be_present
      expect(inspection.errors[:inspection_type]).to be_present
      expect(inspection.errors[:inspection_date]).to be_present
    end
  end

  describe "room observations" do
    it "stores observations as JSON" do
      inspection = create(:inspection,
                         room_observations: {
                           'living_room' => 'Good condition',
                           'kitchen' => 'Minor wear on worktop'
                         })

      expect(inspection.room_observations['living_room']).to eq('Good condition')
      expect(inspection.room_observations['kitchen']).to eq('Minor wear on worktop')
    end
  end

  describe "scopes" do
    let(:agency) { create(:agency) }

    before do
      ActsAsTenant.current_tenant = agency
    end

    it "returns upcoming inspections" do
      upcoming = create(:inspection, inspection_date: 1.day.from_now, status: 'scheduled')
      past = create(:inspection, inspection_date: 1.day.ago, status: 'completed')

      expect(Inspection.upcoming).to include(upcoming)
      expect(Inspection.upcoming).not_to include(past)
    end
  end
end

These tests ensure inspection logic remains correct as features evolve.

What's Next

The inspection foundation enables sophisticated features: inspection templates defining standard room lists and observation prompts for different property types, photo annotation allowing staff to mark specific issues directly on images, comparison views showing changes between consecutive inspections, and automated reminders for scheduled inspections.

Future enhancements might include voice-to-text for hands-free observation capture, AR features measuring rooms using device cameras, integration with maintenance systems automatically creating jobs from inspection issues, and landlord portals providing direct access to inspection reports.

By building a flexible inspection system optimized for mobile use, LetAdmin provides letting agents with tools matching their actual workflows—conducting inspections on-site with phones or tablets, capturing detailed observations efficiently, and maintaining complete records for compliance and landlord communication.