Tuesday, November 4, 2025

Tracking Property Keys: Real-Time Checkout and Accountability

Paul (Founder)
Development
Developer building real-time key tracking system with accountability and automated reminders

Letting agencies manage hundreds of property keys simultaneously—front doors, back doors, communal entrances, meter cupboards, outbuildings. These physical assets enable viewings, maintenance access, and emergency entry, yet many agencies track them using whiteboards, spreadsheets, or verbal handoffs. Keys disappear, staff forget who borrowed which set, and locating the spare key for an urgent viewing becomes a frustrating treasure hunt.

Week 45 implemented comprehensive property key management tracking physical keys digitally throughout their lifecycle. The system records key creation with identification details, handles checkout to staff members with expected return dates, processes returns updating availability automatically, and broadcasts real-time status changes so multiple staff see current key locations instantly. This article explores the key management data model, checkout workflow implementation, real-time updates via ActionCable, and reminder automation for overdue keys.

What Your Team Will Notice

Each property now displays a "Keys" section showing all associated keys: "Front Door - Yale", "Back Door - Chubb", "Meter Cupboard". Each key shows current status—Available (green indicator), Checked Out (amber indicator with holder name), or Overdue (red indicator with days overdue).

Checking out keys requires just two actions: click "Check Out" beside the desired key, select staff member and expected return date from the modal dialogue. The system records checkout timestamp, sends confirmation email to the person collecting keys, and updates the interface instantly showing key as unavailable. Other staff viewing the same property see the status change immediately without page refresh—real-time synchronisation prevents double-bookings where two people believe keys are available.

Returning keys is equally simple: click "Return" beside the checked-out key, optionally add notes about key condition or issues encountered, and the system timestamps the return making keys available again. The checkout history preserves complete audit trail: every checkout and return logged with timestamps, user identities, and purposes.

Overdue keys trigger automatic reminders: if a key remains checked out past expected return date, the system emails the holder daily reminder ("You checked out keys for 123 High Street on Monday, expected return was yesterday"). These gentle nudges reduce the common scenario where staff genuinely forget they still have keys in desk drawers or car glove compartments.

The keys interface provides at-a-glance visibility: properties with available keys show green indicators, properties with all keys checked out show amber, and properties with overdue keys show red. This visual system lets office managers identify problems quickly during morning briefings without interrogating individual records.

Under the Bonnet: Key Management Data Model

The data model balances flexibility with practical constraints:

# db/migrate/...create_property_keys.rb
class CreatePropertyKeys < ActiveRecord::Migration[7.0]
  def change
    create_table :property_keys do |t|
      t.references :property, null: false, foreign_key: true
      t.string :key_type, null: false
      t.string :key_label
      t.text :description
      t.string :location
      t.integer :quantity, default: 1
      t.string :status, default: 'available', null: false

      t.timestamps
    end

    add_index :property_keys, [:property_id, :key_type]
  end
end

# db/migrate/...create_key_checkouts.rb
class CreateKeyCheckouts < ActiveRecord::Migration[7.0]
  def change
    create_table :key_checkouts do |t|
      t.references :property_key, null: false, foreign_key: true
      t.references :user, null: false, foreign_key: true
      t.references :checked_out_by, null: false, foreign_key: { to_table: :users }
      t.datetime :checked_out_at, null: false
      t.datetime :expected_return_at
      t.datetime :returned_at
      t.references :returned_by, foreign_key: { to_table: :users }
      t.text :checkout_notes
      t.text :return_notes
      t.string :checkout_reason

      t.timestamps
    end

    add_index :key_checkouts, [:property_key_id, :returned_at]
    add_index :key_checkouts, :expected_return_at
  end
end

This structure separates key inventory (property_keys) from checkout transactions (key_checkouts), enabling complete history tracking whilst maintaining current availability status efficiently.

Property Key Model

The model handles business logic and validations:

# app/models/property_key.rb
class PropertyKey < ApplicationRecord
  belongs_to :property
  has_many :key_checkouts, dependent: :destroy
  has_one :current_checkout, -> { where(returned_at: nil) }, class_name: 'KeyCheckout'

  validates :key_type, presence: true
  validates :quantity, numericality: { greater_than: 0 }
  validates :status, inclusion: { in: %w[available checked_out unavailable] }

  after_update_commit :broadcast_status_change

  scope :available, -> { where(status: 'available') }
  scope :checked_out, -> { where(status: 'checked_out') }
  scope :overdue, -> {
    joins(:current_checkout)
      .where('key_checkouts.expected_return_at < ?', Time.current)
      .where('key_checkouts.returned_at IS NULL')
  }

  def available?
    status == 'available' && current_checkout.nil?
  end

  def checked_out?
    status == 'checked_out' && current_checkout.present?
  end

  def overdue?
    return false unless checked_out?
    current_checkout.expected_return_at < Time.current
  end

  def display_name
    key_label.presence || "#{key_type.titleize} Key"
  end

  private

  def broadcast_status_change
    ActionCable.server.broadcast(
      "property_#{property_id}_keys",
      {
        event: 'key_status_changed',
        key_id: id,
        status: status,
        current_holder: current_checkout&.user&.name,
        expected_return: current_checkout&.expected_return_at
      }
    )
  end
end

This model provides convenience methods checking availability status, scopes for filtering keys by state, and automatic real-time broadcasting when status changes.

Checkout Workflow Implementation

The checkout process manages state transitions carefully:

# app/models/key_checkout.rb
class KeyCheckout < ApplicationRecord
  belongs_to :property_key
  belongs_to :user
  belongs_to :checked_out_by, class_name: 'User'
  belongs_to :returned_by, class_name: 'User', optional: true

  validates :checked_out_at, presence: true
  validates :user, presence: true
  validate :key_must_be_available, on: :create

  after_create :mark_key_as_checked_out
  after_update :mark_key_as_available, if: :returned?

  scope :active, -> { where(returned_at: nil) }
  scope :overdue, -> {
    active
      .where('expected_return_at < ?', Time.current)
  }

  def returned?
    returned_at.present?
  end

  def overdue?
    return false if returned?
    expected_return_at.present? && expected_return_at < Time.current
  end

  def days_overdue
    return 0 unless overdue?
    ((Time.current - expected_return_at) / 1.day).ceil
  end

  private

  def key_must_be_available
    unless property_key.available?
      errors.add(:property_key, 'is not available for checkout')
    end
  end

  def mark_key_as_checked_out
    property_key.update!(status: 'checked_out')
    KeyCheckoutMailer.checkout_confirmation(self).deliver_later
  end

  def mark_key_as_available
    property_key.update!(status: 'available')
    KeyCheckoutMailer.return_confirmation(self).deliver_later
  end
end

This model enforces business rules: keys cannot be checked out twice simultaneously, returns update availability automatically, and email notifications send asynchronously avoiding request delays.

Checkout Controller

The controller handles user interactions:

# app/controllers/key_checkouts_controller.rb
class KeyCheckoutsController < ApplicationController
  before_action :set_property_key

  def create
    @checkout = @property_key.key_checkouts.build(checkout_params)
    @checkout.checked_out_by = current_user
    @checkout.checked_out_at = Time.current

    if @checkout.save
      redirect_to property_path(@property_key.property),
                  notice: "Key checked out successfully to #{@checkout.user.name}"
    else
      redirect_to property_path(@property_key.property),
                  alert: "Failed to checkout key: #{@checkout.errors.full_messages.join(', ')}"
    end
  end

  def return
    @checkout = @property_key.current_checkout

    unless @checkout
      redirect_to property_path(@property_key.property),
                  alert: 'This key is not currently checked out'
      return
    end

    @checkout.returned_at = Time.current
    @checkout.returned_by = current_user
    @checkout.return_notes = params[:return_notes]

    if @checkout.save
      redirect_to property_path(@property_key.property),
                  notice: 'Key returned successfully'
    else
      redirect_to property_path(@property_key.property),
                  alert: "Failed to return key: #{@checkout.errors.full_messages.join(', ')}"
    end
  end

  private

  def set_property_key
    @property_key = PropertyKey.find(params[:property_key_id])
  end

  def checkout_params
    params.require(:key_checkout).permit(
      :user_id,
      :expected_return_at,
      :checkout_reason,
      :checkout_notes
    )
  end
end

This controller maintains simplicity: create checkouts with validation, process returns with timestamps, and redirect with clear feedback messages.

Real-Time Updates with ActionCable

Key status changes broadcast to all viewing staff:

// app/javascript/controllers/property_keys_controller.js
import consumer from '../channels/consumer'

export function propertyKeys(propertyId) {
  return {
    keys: [],
    subscription: null,

    init() {
      this.loadKeys()
      this.subscribeToUpdates()
    },

    destroy() {
      if (this.subscription) {
        this.subscription.unsubscribe()
      }
    },

    async loadKeys() {
      const response = await fetch(`/properties/${propertyId}/keys.json`)
      this.keys = await response.json()
    },

    subscribeToUpdates() {
      this.subscription = consumer.subscriptions.create(
        {
          channel: 'PropertyKeysChannel',
          property_id: propertyId
        },
        {
          received: (data) => {
            this.handleKeyUpdate(data)
          }
        }
      )
    },

    handleKeyUpdate(data) {
      switch (data.event) {
        case 'key_status_changed':
          this.updateKeyStatus(data)
          break
        case 'key_added':
          this.addKey(data.key)
          break
      }
    },

    updateKeyStatus(data) {
      const key = this.keys.find(k => k.id === data.key_id)
      if (key) {
        key.status = data.status
        key.current_holder = data.current_holder
        key.expected_return = data.expected_return
        this.flashUpdateIndicator(key)
      }
    },

    addKey(keyData) {
      this.keys.push(keyData)
    },

    flashUpdateIndicator(key) {
      // Visual feedback showing which key updated
      const element = this.$refs[`key_${key.id}`]
      if (element) {
        element.classList.add('flash-update')
        setTimeout(() => {
          element.classList.remove('flash-update')
        }, 1000)
      }
    },

    statusBadgeClass(key) {
      if (key.status === 'available') return 'badge-success'
      if (key.expected_return && new Date(key.expected_return) < new Date()) {
        return 'badge-danger'
      }
      return 'badge-warning'
    }
  }
}

This Alpine.js component maintains reactive key state, receives WebSocket updates, and provides visual feedback when changes occur showing staff what updated without requiring manual refresh.

Automated Overdue Reminders

Background jobs handle reminder emails:

# app/jobs/overdue_key_reminder_job.rb
class OverdueKeyReminderJob < ApplicationJob
  queue_as :default

  def perform
    KeyCheckout.overdue.find_each do |checkout|
      KeyCheckoutMailer.overdue_reminder(checkout).deliver_now
    end
  end
end

# config/schedule.rb (using whenever gem)
every 1.day, at: '9:00 am' do
  runner "OverdueKeyReminderJob.perform_later"
end

# app/mailers/key_checkout_mailer.rb
class KeyCheckoutMailer < ApplicationMailer
  def overdue_reminder(checkout)
    @checkout = checkout
    @property = checkout.property_key.property
    @days_overdue = checkout.days_overdue

    mail(
      to: @checkout.user.email,
      subject: "Reminder: Overdue keys for #{@property.display_address}"
    )
  end

  def checkout_confirmation(checkout)
    @checkout = checkout
    @property = checkout.property_key.property

    mail(
      to: @checkout.user.email,
      subject: "Key checkout confirmation: #{@property.display_address}"
    )
  end

  def return_confirmation(checkout)
    @checkout = checkout
    @property = checkout.property_key.property

    mail(
      to: @checkout.user.email,
      subject: "Key return confirmed: #{@property.display_address}"
    )
  end
end

These automated reminders run daily, identifying overdue checkouts and sending gentle nudges encouraging returns without requiring manual intervention.

Testing Key Management

Testing verifies checkout business logic:

RSpec.describe PropertyKey do
  let(:property) { create(:property) }
  let(:key) { create(:property_key, property: property) }
  let(:user) { create(:user) }

  describe '#available?' do
    it 'returns true when status is available and no current checkout' do
      expect(key.available?).to be true
    end

    it 'returns false when checked out' do
      create(:key_checkout, property_key: key, user: user)
      key.update!(status: 'checked_out')

      expect(key.available?).to be false
    end
  end

  describe '#overdue?' do
    it 'returns true when checkout past expected return' do
      create(:key_checkout,
             property_key: key,
             user: user,
             expected_return_at: 1.day.ago)
      key.update!(status: 'checked_out')

      expect(key.overdue?).to be true
    end

    it 'returns false for returned checkouts' do
      create(:key_checkout,
             property_key: key,
             user: user,
             expected_return_at: 1.day.ago,
             returned_at: Time.current)

      expect(key.overdue?).to be false
    end
  end
end

RSpec.describe KeyCheckout do
  let(:key) { create(:property_key) }
  let(:user) { create(:user) }

  describe 'validations' do
    it 'prevents checkout of unavailable key' do
      key.update!(status: 'checked_out')
      checkout = build(:key_checkout, property_key: key, user: user)

      expect(checkout).not_to be_valid
      expect(checkout.errors[:property_key]).to include('is not available for checkout')
    end
  end

  describe 'callbacks' do
    it 'marks key as checked out after creation' do
      create(:key_checkout, property_key: key, user: user)

      expect(key.reload.status).to eq('checked_out')
    end

    it 'marks key as available after return' do
      checkout = create(:key_checkout, property_key: key, user: user)
      checkout.update!(returned_at: Time.current)

      expect(key.reload.status).to eq('available')
    end
  end
end

These tests verify business logic operates correctly: availability checks work accurately, overdue detection functions properly, and state transitions update keys appropriately.

What's Next

The key management foundation enables sophisticated features: QR code generation for each key allowing mobile checkout via camera scan without manual selection, physical keybox integration using Bluetooth or NFC for automated checkout when staff remove keys from secure storage, geolocation tracking recording where checkouts and returns occur providing audit trail of key movements, and predictive analytics identifying keys frequently overdue suggesting process improvements.

Future enhancements might include key set management grouping related keys (front door, back door, garage) for simultaneous checkout, maintenance scheduling triggering automatic key checkouts to engineers before appointments, landlord visibility showing key status in owner portals for transparency, and insurance integration providing key tracking documentation for security compliance requirements.

By implementing comprehensive key management with real-time tracking and automated reminders, LetAdmin provides accountability and visibility for physical assets, reducing lost keys whilst maintaining complete audit trails for security and compliance purposes.