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.