Wednesday, September 3, 2025

Property Advertising Workflow: Granular Control Over Portal Syndication

Paul (Founder)
Features & Workflow
Curious cat with paw on laptop keyboard while code displays on screen

On 3 September 2025, we implemented a comprehensive property advertising workflow system that provides granular control over which properties syndicate to portals like Rightmove, Zoopla, and OnTheMarket. The system uses a two-layer approach: the is_advertised boolean acts as a master switch (on/off), while marketing_status tracks the business process (draft, ready, live, paused, archived). This separation prevents accidental portal publication while maintaining workflow visibility.

For letting agencies, accidental portal publication is expensive and embarrassing. Publishing a draft listing with placeholder text, or advertising a property that's just been let, wastes negotiator time fielding enquiries and damages agency reputation. The previous system (where any "Available to Let" property appeared on portals) lacked sufficient control. The new workflow requires explicit activation, with pre-flight checks ensuring listings are portal-ready.

What Your Team Will Notice

On the property detail page, the Marketing Status card now includes a prominent toggle switch. When toggled on (green), the property advertises to all configured portals. When toggled off (grey), portal syndication is paused regardless of marketing_status. The toggle provides instant visual feedback—no hunting through settings menus to determine advertising state.

The toggle includes intelligent safeguards. If a property's headline is empty, or photos are missing, or the postcode is invalid, the toggle warns staff before allowing activation: "This property cannot be advertised: missing photos and headline." This prevents incomplete listings from reaching portals, where they'd fail validation and trigger error notifications.

The marketing_status field works in tandem. A property can be marked "ready" (indicating content is complete) but not advertised (toggle off). This supports workflows where negotiators prepare listings in advance, quality assurance reviews them, then marketing activates advertising centrally. Or regional offices prepare listings, head office approves, then activation occurs automatically via API.

For agencies with complex approval workflows, this separation is critical. The is_advertised field controls actual portal syndication, while marketing_status tracks internal process. Reports can show "properties ready to advertise but not yet live" or "properties advertising despite marketing status paused" (error conditions worth investigating).

Under the Bonnet: Database Schema

The migration added the is_advertised boolean field:

# db/migrate/20250903080330_add_is_advertised_to_properties.rb
class AddIsAdvertisedToProperties < ActiveRecord::Migration[8.0]
  def change
    add_column :properties, :is_advertised, :boolean, default: false

    add_index :properties, [:agency_id, :is_advertised]
    add_index :properties, [:is_advertised, :marketing_status]
  end
end

The composite index [:agency_id, :is_advertised] optimises queries like "show all advertised properties for this agency"—critical for portal feed generation. The [:is_advertised, :marketing_status] index supports workflow reports.

The default: false ensures new properties don't accidentally advertise—they must be explicitly activated. This safe-by-default approach prevents errors.

A second migration mapped legacy status values to marketing_status:

# db/migrate/20250903092414_map_legacy_status_to_marketing_status.rb
class MapLegacyStatusToMarketingStatus < ActiveRecord::Migration[8.0]
  def up
    # Map imported status to marketing status
    execute <<-SQL
      UPDATE properties
      SET marketing_status = CASE
        WHEN imported_status = 'Available to Let' THEN 'live'
        WHEN imported_status = 'Let' THEN 'archived'
        WHEN imported_status = 'Let STC' THEN 'paused'
        WHEN imported_status = 'Under Negotiation' THEN 'live'
        WHEN imported_status = 'Withdrawn' THEN 'archived'
        ELSE 'draft'
      END
      WHERE imported_status IS NOT NULL
    SQL

    # Only set is_advertised = true for actually advertised properties
    execute <<-SQL
      UPDATE properties
      SET is_advertised = true
      WHERE imported_status IN ('Available to Let', 'Under Negotiation')
        AND marketing_status = 'live'
    SQL
  end

  def down
    # Revert is_advertised to false for safety
    execute "UPDATE properties SET is_advertised = false"
  end
end

This migration ran once per agency during deployment, ensuring existing properties mapped correctly. The conservative approach only set is_advertised = true for properties genuinely advertising before the migration—avoiding accidentally activating dormant listings.

Property Model: Pre-Flight Validation

The Property model includes pre-flight checks that run before allowing advertising:

# app/models/property.rb
class Property < ApplicationRecord
  # Pre-flight validation before advertising
  def ready_to_advertise?
    advertising_blockers.empty?
  end

  def advertising_blockers
    blockers = []

    blockers << "Missing headline" if headline.blank?
    blockers << "Missing description" if description.blank?
    blockers << "No photos uploaded" if property_photos.none?
    blockers << "Missing postcode" if postcode.blank?
    blockers << "Invalid postcode format" unless valid_postcode?
    blockers << "Price not set" if price.blank? || price <= 0
    blockers << "No bedrooms specified" if beds.blank?

    blockers
  end

  def valid_postcode?
    return false if postcode.blank?
    postcode.match?(/\A[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}\z/i)
  end

  # Check if property meets portal-specific requirements
  def portal_compatible?(portal_name)
    case portal_name.downcase
    when "rightmove"
      rightmove_compatible?
    when "zoopla"
      zoopla_compatible?
    else
      ready_to_advertise?
    end
  end

  private

  def rightmove_compatible?
    # Rightmove requires at least 1 photo, max 50 photos
    return false unless property_photos.count.between?(1, 50)

    # Rightmove has headline length requirements
    return false if headline.length > 200

    ready_to_advertise?
  end

  def zoopla_compatible?
    # Zoopla requires at least 1 photo, prefers 6+ photos
    return false unless property_photos.count >= 1

    ready_to_advertise?
  end
end

These methods provide actionable feedback. Rather than "cannot advertise" (unhelpful), the system lists specific blockers: "Missing headline, No photos uploaded, Invalid postcode." Staff know exactly what to fix.

Portal-specific compatibility checks accommodate varying requirements. Rightmove's 200-character headline limit, Zoopla's photo count preferences, and OnTheMarket's floor plan requirements all get validated before activation attempts.

Marketing Status Card: Toggle and Visual Feedback

The Marketing Status card combines status display, toggle control, and action menu:

<!-- app/views/shared/_property_marketing_status_card.html.erb (excerpt) -->
<div class="rounded-sm border border-stroke bg-white px-7.5 py-6 shadow-default dark:border-strokedark dark:bg-boxdark"
     x-data="{
       isAdvertised: <%= property.is_advertised.to_json %>,
       marketingStatus: '<%= property.marketing_status %>',
       updating: false
     }">

  <div class="flex items-center justify-between">
    <div>
      <h4 class="text-title-md font-bold text-black dark:text-white">
        Marketing Status
      </h4>

      <!-- Status badge with success ring animation when live -->
      <div class="mt-2 relative inline-block">
        <span class="inline-flex rounded-full px-3 py-1 text-sm font-medium"
              :class="{
                'bg-gray-100 text-gray-800': marketingStatus === 'draft',
                'bg-blue-100 text-blue-800': marketingStatus === 'ready',
                'bg-green-100 text-green-800': marketingStatus === 'live',
                'bg-orange-100 text-orange-800': marketingStatus === 'paused',
                'bg-gray-100 text-gray-800': marketingStatus === 'archived'
              }">
          <span x-text="marketingStatus.charAt(0).toUpperCase() + marketingStatus.slice(1)"></span>
        </span>

        <!-- Success ring animation when advertising -->
        <template x-if="isAdvertised && marketingStatus === 'live'">
          <span class="absolute top-0 right-0 -mt-1 -mr-1 flex h-3 w-3">
            <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
            <span class="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
          </span>
        </template>
      </div>
    </div>

    <!-- Toggle switch -->
    <label class="flex items-center cursor-pointer">
      <div class="relative">
        <input type="checkbox"
               x-model="isAdvertised"
               @change="toggleAdvertising()"
               class="sr-only"
               :disabled="updating">

        <div class="block w-14 h-8 rounded-full transition"
             :class="isAdvertised ? 'bg-primary' : 'bg-gray-300'"></div>

        <div class="dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition transform"
             :class="isAdvertised ? 'translate-x-6' : ''"></div>
      </div>
    </label>
  </div>

  <!-- Advertising blockers warning -->
  <template x-if="!isAdvertised">
    <% unless property.ready_to_advertise? %>
      <div class="mt-4 p-3 rounded-lg bg-amber-50 border border-amber-200">
        <p class="text-sm font-medium text-amber-900">Cannot advertise:</p>
        <ul class="mt-2 text-sm text-amber-800 list-disc list-inside">
          <% property.advertising_blockers.each do |blocker| %>
            <li><%= blocker %></li>
          <% end %>
        </ul>
      </div>
    <% end %>
  </template>
</div>

<script>
function toggleAdvertising() {
  <% unless property.ready_to_advertise? %>
    if (!this.isAdvertised) {
      alert("This property cannot be advertised. Please fix the listed issues first.");
      this.isAdvertised = false;
      return;
    }
  <% end %>

  this.updating = true;

  fetch('<%= property_path(property) %>', {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
    },
    body: JSON.stringify({
      property: {
        is_advertised: this.isAdvertised
      }
    })
  })
  .then(response => {
    if (!response.ok) throw new Error('Update failed');
    return response.json();
  })
  .then(data => {
    this.marketingStatus = data.marketing_status;
    // Show success notification
    showNotification('Advertising status updated successfully');
  })
  .catch(error => {
    alert('Failed to update advertising status. Please try again.');
    this.isAdvertised = !this.isAdvertised; // Revert toggle
  })
  .finally(() => {
    this.updating = false;
  });
}
</script>

The Alpine.js component manages state (isAdvertised, marketingStatus, updating) and handles toggle interactions. The @change event fires toggleAdvertising(), which sends a PATCH request to update the property.

The success ring animation (:animate-ping) provides delightful visual feedback when properties are actively advertising—a small detail that makes the interface feel polished.

The conditional rendering (<template x-if>) shows advertising blockers only when relevant. If the property is already advertising, the warning is hidden. If it's not advertising but has blockers, they're prominently displayed with actionable fix instructions.

API Integration: Portal Feed Generation

When portal sync jobs run, they query advertised properties:

# app/jobs/rightmove_sync_job.rb (illustrative, not from Week 36)
class RightmoveSyncJob < ApplicationJob
  def perform(agency_id)
    agency = Agency.find(agency_id)
    ActsAsTenant.current_tenant = agency

    # Only sync advertised properties that meet portal requirements
    properties = agency.properties
                       .where(is_advertised: true)
                       .select { |p| p.portal_compatible?("rightmove") }

    xml = RightmoveFeedBuilder.new(properties).to_xml

    # Upload XML to Rightmove FTP server
    RightmoveFtpUploader.upload(agency, xml)
  end
end

The is_advertised flag gates portal inclusion—simple, explicit, and foolproof. Even if a property's marketing_status is "live," it won't appear in portal feeds unless is_advertised = true.

This two-layer approach supports sophisticated workflows:

  • Draft listings (marketing_status: "draft", is_advertised: false)
  • QA review (marketing_status: "ready", is_advertised: false)
  • Live but paused (marketing_status: "paused", is_advertised: false)
  • Actively advertising (marketing_status: "live", is_advertised: true)

Testing Advertising Logic

The test suite verifies advertising controls:

# spec/models/property_spec.rb
RSpec.describe Property do
  describe "#ready_to_advertise?" do
    it "returns false when headline is missing" do
      property = build(:property, headline: nil)
      expect(property).not_to be_ready_to_advertise
      expect(property.advertising_blockers).to include("Missing headline")
    end

    it "returns false when no photos uploaded" do
      property = create(:property)
      expect(property).not_to be_ready_to_advertise
      expect(property.advertising_blockers).to include("No photos uploaded")
    end

    it "returns true when all requirements met" do
      property = create(:property, :with_photos, headline: "Test", description: "Test property", postcode: "M1 1AA", price: 1000, beds: 2)
      expect(property).to be_ready_to_advertise
    end
  end

  describe "#portal_compatible?" do
    let(:property) { create(:property, :with_photos, headline: "Test", description: "Test", postcode: "M1 1AA", price: 1000, beds: 2) }

    it "checks Rightmove headline length" do
      property.headline = "A" * 250
      expect(property.portal_compatible?("rightmove")).to be false
    end

    it "validates Zoopla photo count" do
      property.property_photos.destroy_all
      expect(property.portal_compatible?("zoopla")).to be false
    end
  end
end

These tests ensure pre-flight checks work correctly and portal-specific validations catch incompatible listings.

What's Next

The advertising workflow foundation enables future enhancements:

  • Scheduled activation: Queue properties to start advertising on specific dates
  • A/B testing: Advertise properties with different headlines/photos on different portals
  • Performance analytics: Track portal-specific performance (views, enquiries, applications per portal)
  • Approval workflows: Require manager approval before activating expensive portal slots

But the core system—two-layer advertising control with pre-flight validation and portal-specific compatibility checks—was established on 3 September, providing letting agencies with the granular control they need for professional portal syndication.


Related articles: