Thursday, September 18, 2025

Building Intelligent Property Status Workflows

Paul (Founder)
Development
Developer designing user experience workflows by window with natural light

Property status changes have implications beyond simply updating a database field. Marking a property "Let" should stop advertising automatically. Changing to "Available to Let" should offer to start advertising. Attempting to advertise a property with an F-rated EPC should warn about legal restrictions. Week 38 implemented intelligent status workflows that understand these relationships, preventing common mistakes whilst guiding users through correct processes.

This article explores how we built context-aware status management that makes correct workflows easy and incorrect ones difficult—reducing errors whilst improving operational efficiency.

What Your Team Will Notice

The status change interface adapts based on current property state and target status. Change a property from "Available to Let" to "Let" and the system automatically stops advertising across all portals, updates webhook subscribers, and records the status transition with timestamps. No need to remember separate "stop advertising" steps—the platform handles it intelligently.

Switch a property from "Let" back to "Available to Let" and the interface displays an advertising checklist: EPC present and valid, photos uploaded, rent price set, property description complete. Items marked complete show green checkmarks; missing requirements highlight in amber with quick-fix links. When all requirements are met, "Start Advertising" becomes available with one click.

The status dropdown respects business logic automatically. Properties can't transition from "Available to Let" to "Withdrawn" without passing through intermediate states. Status changes that have advertising implications show contextual notices: "This will stop advertising on Rightmove and Zoopla" or "This property is ready to advertise—would you like to start now?"

For properties with compliance issues (expired EPCs, F/G ratings, missing required fields), status changes include warnings before allowing transitions that would create legal risks. The system prevents advertising properties that shouldn't be advertised whilst allowing status updates for legitimate reasons (withdrawals, lettings, maintenance periods).

Under the Bonnet: State Machine Logic

Property marketing status follows a defined state machine with valid transitions and business rules:

class Property
  # Marketing status options
  MARKETING_STATUSES = {
    'not_advertised' => 'Not For Advertising',
    'available_to_let' => 'Available to Let',
    'under_negotiation' => 'Under Negotiation',
    'let_stc' => 'Let STC',
    'let' => 'Let',
    'withdrawn' => 'Withdrawn'
  }.freeze

  # Statuses that should actively advertise
  ADVERTISING_STATUSES = %w[available_to_let under_negotiation].freeze

  # Statuses that should not advertise
  NON_ADVERTISING_STATUSES = %w[let_stc let withdrawn not_advertised].freeze

  def can_advertise?
    ADVERTISING_STATUSES.include?(marketing_status) &&
      advertising_requirements_met?
  end

  def advertising_requirements_met?
    # All required fields present
    price.present? &&
      beds.present? &&
      bathrooms.present? &&
      property_type.present? &&
      description.present? &&
      photos.any? &&
      epc_valid_for_advertising?
  end

  def epc_valid_for_advertising?
    return false unless current_epc.present?
    return false unless current_epc.valid?

    # Can't advertise F or G rated properties
    %w[A B C D E].include?(current_epc.current_energy_rating)
  end
end

These methods power conditional logic throughout status change workflows.

Context-Aware Status Change Modal

The status change modal displays different content based on current status and target status:

class ChangeStatusModal {
  constructor(propertyId, currentStatus) {
    this.propertyId = propertyId;
    this.currentStatus = currentStatus;
    this.targetStatus = null;
  }

  selectStatus(status) {
    this.targetStatus = status;
    this.updateModalContent();
  }

  updateModalContent() {
    const transition = {
      from: this.currentStatus,
      to: this.targetStatus
    };

    // Check if this transition stops advertising
    if (this.stopsAdvertising(transition)) {
      this.showAdvertisingStopNotice();
    }

    // Check if this transition enables advertising
    if (this.enablesAdvertising(transition)) {
      this.showAdvertisingStartOption();
    }

    // Check for compliance warnings
    if (this.hasComplianceIssues(transition)) {
      this.showComplianceWarnings();
    }
  }

  stopsAdvertising(transition) {
    const advertisingStatuses = ['available_to_let', 'under_negotiation'];
    const nonAdvertisingStatuses = ['let_stc', 'let', 'withdrawn', 'not_advertised'];

    return advertisingStatuses.includes(transition.from) &&
           nonAdvertisingStatuses.includes(transition.to);
  }

  enablesAdvertising(transition) {
    const nonAdvertisingStatuses = ['let_stc', 'let', 'withdrawn', 'not_advertised'];
    const advertisingStatuses = ['available_to_let', 'under_negotiation'];

    return nonAdvertisingStatuses.includes(transition.from) &&
           advertisingStatuses.includes(transition.to);
  }
}

This JavaScript layer provides immediate visual feedback before form submission.

Advertising Readiness Checklist

When status changes enable advertising, the modal displays a real-time requirements checklist:

<div x-data="advertisingChecklist" x-show="showAdvertisingOption">
  <h3>Advertising Readiness</h3>

  <ul class="checklist">
    <!-- Required fields -->
    <li :class="checkmark(hasPrice)">
      <span x-text="icon(hasPrice)"></span>
      Rent price set
      <a x-show="!hasPrice" href="#" @click="quickFix('price')">Set now</a>
    </li>

    <li :class="checkmark(hasPhotos)">
      <span x-text="icon(hasPhotos)"></span>
      Photos uploaded (<span x-text="photoCount"></span>)
      <a x-show="!hasPhotos" href="#" @click="quickFix('photos')">Add photos</a>
    </li>

    <li :class="checkmark(hasDescription)">
      <span x-text="icon(hasDescription)"></span>
      Property description
      <a x-show="!hasDescription" href="#" @click="quickFix('description')">Add description</a>
    </li>

    <li :class="checkmark(hasValidEpc)">
      <span x-text="icon(hasValidEpc)"></span>
      Valid EPC (Rating: <span x-text="epcRating"></span>)
      <a x-show="!hasValidEpc" href="#" @click="quickFix('epc')">Add EPC</a>
    </li>
  </ul>

  <button type="button"
          @click="startAdvertising"
          :disabled="!allRequirementsMet"
          :class="{ 'btn-primary': allRequirementsMet, 'btn-disabled': !allRequirementsMet }">
    <span x-show="allRequirementsMet">Start Advertising</span>
    <span x-show="!allRequirementsMet">Complete Requirements First</span>
  </button>
</div>

<script>
Alpine.data('advertisingChecklist', () => ({
  hasPrice: <%= @property.price.present? %>,
  hasPhotos: <%= @property.photos.any? %>,
  photoCount: <%= @property.photos.count %>,
  hasDescription: <%= @property.description.present? %>,
  hasValidEpc: <%= @property.epc_valid_for_advertising? %>,
  epcRating: '<%= @property.current_epc&.current_energy_rating %>',

  get allRequirementsMet() {
    return this.hasPrice && this.hasPhotos &&
           this.hasDescription && this.hasValidEpc;
  },

  checkmark(condition) {
    return condition ? 'checklist-complete' : 'checklist-incomplete';
  },

  icon(condition) {
    return condition ? '✓' : '○';
  },

  quickFix(field) {
    // Navigate to appropriate modal/section to fix missing requirement
  }
}));
</script>

This checklist updates in real-time as requirements are met, providing clear path to advertising eligibility.

Automatic Advertising Management

When status transitions warrant advertising changes, the system handles them automatically:

class PropertiesController
  def update_status
    @property = Property.find(params[:id])
    old_status = @property.marketing_status
    new_status = params[:marketing_status]

    @property.update!(marketing_status: new_status)

    # Handle advertising implications
    handle_advertising_for_status_change(old_status, new_status)

    # Trigger webhooks for external systems
    @property.trigger_webhooks('property.status.updated')

    redirect_to @property, notice: status_change_message(old_status, new_status)
  end

  private

  def handle_advertising_for_status_change(old_status, new_status)
    stopping_advertising = advertising_status?(old_status) &&
                          !advertising_status?(new_status)

    starting_advertising = !advertising_status?(old_status) &&
                           advertising_status?(new_status) &&
                           params[:start_advertising] == 'true'

    if stopping_advertising
      stop_advertising(@property)
    elsif starting_advertising
      start_advertising(@property)
    end
  end

  def stop_advertising(property)
    property.update!(advertising: false)

    # Trigger portal removals via webhooks
    property.trigger_webhooks('property.advertising.stopped')

    # Log activity
    AuditLog.log('stopped_advertising', property, current_user)
  end

  def start_advertising(property)
    return unless property.advertising_requirements_met?

    property.update!(advertising: true)

    # Trigger portal additions via webhooks
    property.trigger_webhooks('property.advertising.started')

    # Log activity
    AuditLog.log('started_advertising', property, current_user)
  end

  def advertising_status?(status)
    Property::ADVERTISING_STATUSES.include?(status)
  end
end

This automation ensures advertising state stays synchronized with marketing status without requiring separate actions.

EPC Compliance Warnings

The status change interface checks EPC compliance before allowing advertising:

<% if @property.status_enables_advertising?(target_status) %>
  <% unless @property.epc_valid_for_advertising? %>
    <div class="alert alert-warning">
      <strong>EPC Warning:</strong>

      <% if @property.current_epc.blank? %>
        This property has no EPC certificate. EPCs are legally required for all rental properties.
        <a href="<%= property_epc_path(@property) %>">Add EPC now</a>
      <% elsif @property.current_epc.expired? %>
        This property's EPC expired on <%= @property.current_epc.expiry_date.to_formatted_s(:long) %>.
        <a href="<%= property_epc_path(@property) %>">Update EPC</a>
      <% elsif ['F', 'G'].include?(@property.current_epc.current_energy_rating) %>
        This property has an EPC rating of <%= @property.current_epc.current_energy_rating %>.
        Properties rated F or G cannot be legally let and should not be advertised.
        <a href="<%= property_epc_path(@property) %>">Check details</a>
      <% end %>
    </div>
  <% end %>
<% end %>

These warnings appear before form submission, preventing non-compliant advertising attempts.

Streamlined Wizard Steps

For properties already advertising, the edit advertising wizard automatically skips unnecessary steps:

function calculateWizardSteps(property) {
  const steps = [];

  // Step 1: Marketing status and advertising decision
  // Only show if property isn't currently advertising
  if (!property.advertising) {
    steps.push('marketing_status_and_advertising');
  }

  // Step 2: Property details (always shown)
  steps.push('property_details');

  // Step 3: Photos and EPC (always shown)
  steps.push('photos_and_epc');

  // Step 4: Review and publish (always shown)
  steps.push('review');

  return steps;
}

Properties not advertising see four steps; those already advertising see three. This reduces friction for common updates.

Testing Workflow Logic

Comprehensive tests verify status transition logic and advertising implications:

RSpec.describe Property, type: :model do
  describe "advertising status logic" do
    it "identifies statuses that should advertise" do
      property = build(:property, marketing_status: 'available_to_let')
      expect(property.can_advertise?).to be true
    end

    it "identifies statuses that should not advertise" do
      property = build(:property, marketing_status: 'let')
      expect(property.can_advertise?).to be false
    end

    it "prevents advertising with invalid EPC" do
      property = create(:property, marketing_status: 'available_to_let')
      create(:epc, property: property, current_energy_rating: 'F')

      expect(property.can_advertise?).to be false
    end
  end

  describe "status transitions" do
    it "stops advertising when changing to let" do
      property = create(:property,
                       marketing_status: 'available_to_let',
                       advertising: true)

      property.update!(marketing_status: 'let')

      expect(property.advertising).to be false
    end

    it "enables advertising option when changing to available" do
      property = create(:property, marketing_status: 'let')

      property.update!(marketing_status: 'available_to_let')

      expect(property.status_enables_advertising?).to be true
    end
  end
end

These tests ensure business logic behaves correctly across various scenarios.

What's Next

The intelligent workflow foundation enables several enhancements: status transition history showing complete property lifecycle, automated reminders when properties remain in transient statuses too long (properties "Under Negotiation" for weeks likely need attention), and predictive status suggestions based on property activity patterns.

Future improvements might include role-based status transition restrictions (only managers can mark properties "Let"), approval workflows for significant status changes (require manager approval before withdrawing advertised properties), and integration with tenant management (automatically transition to "Let" when tenancy agreements are signed).

By embedding business logic into user interfaces, LetAdmin reduces errors, improves compliance, and guides users through correct processes—transforming complex property management workflows into straightforward, mistake-resistant operations.