Skip to content

Commit b38e87f

Browse files
etewiahclaude
andcommitted
Add SppListing content management PUT endpoint (Phase 6)
Allows SPP admin UI to update listing content — translated texts via Mobility, price, curated photo order, highlighted features, template, settings, and arbitrary extra_data. Validates photo IDs belong to the same property. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 52ee838 commit b38e87f

File tree

3 files changed

+336
-1
lines changed

3 files changed

+336
-1
lines changed

app/controllers/api_manage/v1/spp_listings_controller.rb

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ module V1
88
# POST /api_manage/v1/:locale/properties/:id/spp_publish — Publish an SPP listing
99
# POST /api_manage/v1/:locale/properties/:id/spp_unpublish — Unpublish an SPP listing
1010
# GET /api_manage/v1/:locale/properties/:id/spp_leads — Retrieve property enquiries
11+
# PUT /api_manage/v1/:locale/spp_listings/:id — Update SPP listing content
1112
#
1213
class SppListingsController < BaseController
1314
before_action :require_user!
14-
before_action :set_property
15+
before_action :set_property, only: %i[publish unpublish leads]
16+
before_action :set_spp_listing, only: %i[update]
17+
before_action :setup_locale, only: %i[update]
1518

1619
# POST /api_manage/v1/:locale/properties/:id/spp_publish
1720
def publish
@@ -78,6 +81,24 @@ def leads
7881
render json: messages.map { |msg| lead_json(msg) }
7982
end
8083

84+
# PUT /api_manage/v1/:locale/spp_listings/:id
85+
def update
86+
if spp_listing_params.key?(:photo_ids_ordered)
87+
return unless valid_photo_ids?
88+
end
89+
90+
attrs = spp_listing_params.to_h
91+
# Ensure photo IDs are stored as integers in JSONB
92+
if attrs.key?('photo_ids_ordered')
93+
attrs['photo_ids_ordered'] = attrs['photo_ids_ordered'].map(&:to_s).reject(&:blank?).map(&:to_i)
94+
end
95+
96+
@spp_listing.assign_attributes(attrs)
97+
@spp_listing.save!
98+
99+
render json: spp_listing_json(@spp_listing)
100+
end
101+
81102
private
82103

83104
def set_property
@@ -104,6 +125,73 @@ def lead_json(message)
104125
isNew: !message.read? || message.created_at > 48.hours.ago
105126
}
106127
end
128+
129+
def set_spp_listing
130+
@spp_listing = Pwb::SppListing
131+
.joins(:realty_asset)
132+
.where(pwb_realty_assets: { website_id: current_website.id })
133+
.find(params[:id])
134+
end
135+
136+
def setup_locale
137+
locale = params[:locale] || I18n.default_locale
138+
I18n.locale = locale.to_sym
139+
end
140+
141+
def spp_listing_params
142+
params.permit(
143+
:title, :description, :seo_title, :meta_description,
144+
:price_cents, :price_currency,
145+
:template,
146+
photo_ids_ordered: [],
147+
highlighted_features: [],
148+
spp_settings: {},
149+
extra_data: {}
150+
)
151+
end
152+
153+
def valid_photo_ids?
154+
ids = spp_listing_params[:photo_ids_ordered]
155+
return true if ids.blank?
156+
157+
# Filter out blanks that can come from empty array serialization
158+
int_ids = ids.map(&:to_s).reject(&:blank?).map(&:to_i)
159+
return true if int_ids.empty?
160+
161+
valid_ids = @spp_listing.realty_asset.prop_photos.pluck(:id)
162+
invalid = int_ids - valid_ids
163+
return true if invalid.empty?
164+
165+
render json: {
166+
success: false,
167+
error: 'Invalid photo IDs',
168+
message: "Photo IDs #{invalid.join(', ')} do not belong to this property"
169+
}, status: :unprocessable_entity
170+
false
171+
end
172+
173+
def spp_listing_json(listing)
174+
{
175+
id: listing.id,
176+
listingType: listing.listing_type,
177+
title: listing.title,
178+
description: listing.description,
179+
seoTitle: listing.seo_title,
180+
metaDescription: listing.meta_description,
181+
priceCents: listing.price_cents,
182+
priceCurrency: listing.price_currency,
183+
photoIdsOrdered: listing.photo_ids_ordered,
184+
highlightedFeatures: listing.highlighted_features,
185+
template: listing.template,
186+
sppSettings: listing.spp_settings,
187+
extraData: listing.extra_data,
188+
active: listing.active,
189+
visible: listing.visible,
190+
liveUrl: listing.live_url,
191+
publishedAt: listing.published_at&.iso8601,
192+
updatedAt: listing.updated_at.iso8601
193+
}
194+
end
107195
end
108196
end
109197
end

config/routes.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,9 @@
843843
end
844844
end
845845

846+
# SPP listing content management (standalone resource)
847+
resources :spp_listings, only: [:update], controller: 'spp_listings'
848+
846849
# AI-powered social media content generation
847850
namespace :ai do
848851
resources :social_posts, only: [:index, :show, :create, :update, :destroy] do
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe 'ApiManage::V1::SppListings#update', type: :request do
6+
let!(:website) { create(:pwb_website) }
7+
let!(:user) { create(:pwb_user, :admin, website: website) }
8+
let!(:property) { create(:pwb_realty_asset, website: website) }
9+
let!(:spp_listing) do
10+
create(:pwb_spp_listing, :published,
11+
realty_asset: property,
12+
listing_type: 'sale')
13+
end
14+
15+
let(:auth_headers) do
16+
{
17+
'HTTP_HOST' => "#{website.subdomain}.localhost",
18+
'X-User-Email' => user.email
19+
}
20+
end
21+
22+
let(:endpoint) { "/api_manage/v1/en/spp_listings/#{spp_listing.id}" }
23+
24+
before do
25+
ActsAsTenant.current_tenant = website
26+
end
27+
28+
after do
29+
ActsAsTenant.current_tenant = nil
30+
end
31+
32+
# ============================================
33+
# Basic Content Updates
34+
# ============================================
35+
describe 'updating translated fields' do
36+
it 'updates title via Mobility in the specified locale' do
37+
put endpoint, params: { title: 'Your Dream Mediterranean Retreat' }, headers: auth_headers
38+
39+
expect(response).to have_http_status(:ok)
40+
json = response.parsed_body
41+
expect(json['title']).to eq('Your Dream Mediterranean Retreat')
42+
43+
spp_listing.reload
44+
Mobility.with_locale(:en) do
45+
expect(spp_listing.title).to eq('Your Dream Mediterranean Retreat')
46+
end
47+
end
48+
49+
it 'stores translations in the correct locale' do
50+
put "/api_manage/v1/es/spp_listings/#{spp_listing.id}",
51+
params: { title: 'Tu Refugio Mediterraneo' },
52+
headers: auth_headers
53+
54+
expect(response).to have_http_status(:ok)
55+
56+
spp_listing.reload
57+
Mobility.with_locale(:es) do
58+
expect(spp_listing.title).to eq('Tu Refugio Mediterraneo')
59+
end
60+
end
61+
62+
it 'updates description and SEO fields' do
63+
put endpoint, params: {
64+
description: 'Imagine waking up to the sound of waves...',
65+
seo_title: 'Luxury Biarritz Apartment',
66+
meta_description: 'Stunning 3-bed apartment in Biarritz...'
67+
}, headers: auth_headers
68+
69+
expect(response).to have_http_status(:ok)
70+
json = response.parsed_body
71+
expect(json['description']).to eq('Imagine waking up to the sound of waves...')
72+
expect(json['seoTitle']).to eq('Luxury Biarritz Apartment')
73+
expect(json['metaDescription']).to eq('Stunning 3-bed apartment in Biarritz...')
74+
end
75+
end
76+
77+
# ============================================
78+
# Price Updates
79+
# ============================================
80+
describe 'updating price' do
81+
it 'updates price_cents and price_currency' do
82+
put endpoint, params: { price_cents: 550_000_00, price_currency: 'USD' }, headers: auth_headers
83+
84+
expect(response).to have_http_status(:ok)
85+
json = response.parsed_body
86+
expect(json['priceCents']).to eq(550_000_00)
87+
expect(json['priceCurrency']).to eq('USD')
88+
end
89+
end
90+
91+
# ============================================
92+
# Photo IDs Ordered
93+
# ============================================
94+
describe 'updating photo_ids_ordered' do
95+
let!(:photo1) { create(:pwb_prop_photo, realty_asset: property, sort_order: 1) }
96+
let!(:photo2) { create(:pwb_prop_photo, realty_asset: property, sort_order: 2) }
97+
let!(:photo3) { create(:pwb_prop_photo, realty_asset: property, sort_order: 3) }
98+
99+
it 'accepts valid photo IDs belonging to the same property' do
100+
put endpoint, params: { photo_ids_ordered: [photo3.id, photo1.id] }, headers: auth_headers
101+
102+
expect(response).to have_http_status(:ok)
103+
json = response.parsed_body
104+
expect(json['photoIdsOrdered']).to eq([photo3.id, photo1.id])
105+
end
106+
107+
it 'rejects photo IDs from a different property' do
108+
other_property = create(:pwb_realty_asset, website: website)
109+
other_photo = create(:pwb_prop_photo, realty_asset: other_property)
110+
111+
put endpoint, params: { photo_ids_ordered: [photo1.id, other_photo.id] }, headers: auth_headers
112+
113+
expect(response).to have_http_status(:unprocessable_entity)
114+
json = response.parsed_body
115+
expect(json['error']).to eq('Invalid photo IDs')
116+
expect(json['message']).to include(other_photo.id.to_s)
117+
end
118+
119+
it 'accepts an empty array to reset to default order' do
120+
spp_listing.update!(photo_ids_ordered: [photo2.id, photo1.id])
121+
122+
put endpoint, params: { photo_ids_ordered: [] }, headers: auth_headers
123+
124+
expect(response).to have_http_status(:ok)
125+
json = response.parsed_body
126+
expect(json['photoIdsOrdered']).to eq([])
127+
end
128+
end
129+
130+
# ============================================
131+
# Highlighted Features
132+
# ============================================
133+
describe 'updating highlighted_features' do
134+
it 'accepts an array of feature keys' do
135+
put endpoint, params: { highlighted_features: %w[sea_views private_pool parking] }, headers: auth_headers
136+
137+
expect(response).to have_http_status(:ok)
138+
json = response.parsed_body
139+
expect(json['highlightedFeatures']).to eq(%w[sea_views private_pool parking])
140+
end
141+
end
142+
143+
# ============================================
144+
# Template and Settings
145+
# ============================================
146+
describe 'updating template and spp_settings' do
147+
it 'updates the template' do
148+
put endpoint, params: { template: 'modern' }, headers: auth_headers
149+
150+
expect(response).to have_http_status(:ok)
151+
json = response.parsed_body
152+
expect(json['template']).to eq('modern')
153+
end
154+
155+
it 'updates spp_settings' do
156+
put endpoint, params: { spp_settings: { color_scheme: 'dark', layout: 'full-width' } }, headers: auth_headers
157+
158+
expect(response).to have_http_status(:ok)
159+
json = response.parsed_body
160+
expect(json['sppSettings']).to eq({ 'color_scheme' => 'dark', 'layout' => 'full-width' })
161+
end
162+
end
163+
164+
# ============================================
165+
# Extra Data (Arbitrary JSON)
166+
# ============================================
167+
describe 'updating extra_data' do
168+
it 'accepts arbitrary JSON' do
169+
extra = {
170+
agent_name: 'Marie Dupont',
171+
agent_phone: '+33 6 12 34 56 78',
172+
video_tour_url: 'https://youtube.com/watch?v=abc123'
173+
}
174+
175+
put endpoint, params: { extra_data: extra }, headers: auth_headers
176+
177+
expect(response).to have_http_status(:ok)
178+
json = response.parsed_body
179+
expect(json['extraData']['agent_name']).to eq('Marie Dupont')
180+
expect(json['extraData']['video_tour_url']).to eq('https://youtube.com/watch?v=abc123')
181+
end
182+
end
183+
184+
# ============================================
185+
# Response Format
186+
# ============================================
187+
describe 'response format' do
188+
it 'returns the full SppListing JSON' do
189+
put endpoint, params: { title: 'Updated Title' }, headers: auth_headers
190+
191+
expect(response).to have_http_status(:ok)
192+
json = response.parsed_body
193+
expect(json).to include(
194+
'id', 'listingType', 'title', 'description',
195+
'seoTitle', 'metaDescription', 'priceCents', 'priceCurrency',
196+
'photoIdsOrdered', 'highlightedFeatures', 'template',
197+
'sppSettings', 'extraData', 'active', 'visible',
198+
'liveUrl', 'publishedAt', 'updatedAt'
199+
)
200+
end
201+
end
202+
203+
# ============================================
204+
# Authentication & Authorization
205+
# ============================================
206+
describe 'authentication' do
207+
it 'returns 401 without authentication' do
208+
put endpoint, params: { title: 'Test' },
209+
headers: { 'HTTP_HOST' => "#{website.subdomain}.localhost" }
210+
211+
expect(response).to have_http_status(:unauthorized)
212+
end
213+
end
214+
215+
# ============================================
216+
# Tenant Isolation
217+
# ============================================
218+
describe 'tenant isolation' do
219+
it 'returns 404 for SppListings belonging to other tenants' do
220+
other_website = create(:pwb_website, subdomain: 'other-tenant')
221+
other_property = create(:pwb_realty_asset, website: other_website)
222+
other_listing = create(:pwb_spp_listing, :published, realty_asset: other_property)
223+
224+
put "/api_manage/v1/en/spp_listings/#{other_listing.id}",
225+
params: { title: 'Hijack attempt' },
226+
headers: auth_headers
227+
228+
expect(response).to have_http_status(:not_found)
229+
end
230+
end
231+
232+
# ============================================
233+
# Not Found
234+
# ============================================
235+
describe 'non-existent listing' do
236+
it 'returns 404 for unknown IDs' do
237+
put '/api_manage/v1/en/spp_listings/00000000-0000-0000-0000-000000000000',
238+
params: { title: 'Test' },
239+
headers: auth_headers
240+
241+
expect(response).to have_http_status(:not_found)
242+
end
243+
end
244+
end

0 commit comments

Comments
 (0)