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:
