Class: Crm::SiteMapsController

Inherits:
CrmController show all
Defined in:
app/controllers/crm/site_maps_controller.rb

Overview

SEO dashboard for SiteMap pages: search/filter, edge-cache purge,
sitemap regeneration, per-page AI analysis, and keyword/GSC/GA4 syncing.

Constant Summary collapse

KEYWORD_LOOKUP_LIMIT =

Max results returned by #keyword_lookup's TomSelect endpoint.

20
PATH_PREDICATES =

Path predicates.

{
  'contains'    => ->(val) { ['site_maps.path ILIKE ?', "%#{val}%"] },
  'starts_with' => ->(val) { ['site_maps.path ILIKE ?', "#{val}%"] },
  'ends_with'   => ->(val) { ['site_maps.path ILIKE ?', "%#{val}"] },
  'equals'      => ->(val) { ['site_maps.path = ?', val] }
}.freeze

Constants included from Controllers::ReferenceFindable

Controllers::ReferenceFindable::ID_EMBEDDED_PATTERNS

Constants included from Controllers::AnalyticsEvents

Controllers::AnalyticsEvents::MAX_QUEUED_EVENTS, Controllers::AnalyticsEvents::SESSION_KEY

Constants included from Controllers::ErrorRendering

Controllers::ErrorRendering::NON_CONTENT_PATH_PREFIXES

Instance Method Summary collapse

Methods inherited from CrmController

#access_denied, #context_id, #context_object, #crm_home_path, #current_ability, #default_url_options, #download_temp, #get_tempfile_path_for_download, #init_status_job_collector, #initialize_crm_lazy_chunks, #persist_enqueued_status_jobs, #record_not_found, #redirect_to_job_or_fallback, #render_edit_action, #set_context, #set_download_path, #stash_file_for_temp_download, #sync_admin_presence_cookie, #touch_employee_last_seen

Methods inherited from ApplicationController

#account_impersonated?, #add_to_flash, #after_sign_in_path_for, #bypass_forgery_protection?, #chat_enabled?, #cloudflare_cleared?, #default_catalog, #default_url_options, #enable_turbo_frames, #find_publication, #fix_invalid_accept_header, #init_js_utils, #is_globals_call?, #layout_by_resource, #locale_store, #redirect_to, #require_employee_for_crm, #set_base_host, #set_real_ip, #set_report_errors_for, #should_render_layout?, #skip_layout_for_turbo_frame?, #stamp_impersonation_context, #tab_frame_breakout_request?, #warmlyyours_canada_ip?, #warmlyyours_ip?, #y

Methods included from Controllers::ReturnPathHandling

#check_for_return_path, #redirect_to_return_path_or_default

Methods included from Controllers::AnalyticsEvents

#consume_queued_analytics_events, #registration_lead_type, #track_event

Methods included from Controllers::DeviceDetection

#device_detector, #is_ie?

Methods included from Controllers::SubdomainDetection

#is_crm_request?, #is_www_request?, #json_request?

Methods included from Controllers::TurboSafeRedirect

#redirect_to

Methods included from Controllers::TrackingDetection

#bot_request?, #gdpr_country?, #gdpr_country_data, #prevent_bots, #set_tracking_cookie, #track_visitor?

Methods included from Controllers::AcceleratedFileSending

#send_file_accelerated, #send_upload_accelerated

Methods included from Controllers::ErrorRendering

#excp_string, #mail_to_for_error_reporting, #render_400, #render_404, #render_406, #render_410, #render_500, #render_invalid_authenticity_token, #render_ip_spoof_error, #render_unpermitted_parameters, #safe_referer_or_fallback

Methods included from Controllers::TurnstileVerification

#load_turnstile_script_tag, #turnstile_lazy_widget, #turnstile_script_tag, #turnstile_widget, #validate_turnstile!

Methods included from Controllers::CloudflareCaching

edge_cached, #edge_cached_action?, #reset_cloudflare_cache, #set_cloudflare_cache, #skip_edge_cache!, #skip_session

Methods included from Controllers::Webpackable

#preload_webpack_fonts, #webpack_css_include, #webpack_css_url, #webpack_js_include, #wpd_is_running?

Methods included from Controllers::Localizable

#cloudflare_country_locale, #determine_request_locale, #geocoder_locale, #guest_user_locale_check, #locale_optional_www_auth_path?, #param_locale, #set_locale, #set_request_locale, #skip_localization?, #warmlyyours_ip_locale

Methods included from Controllers::Authenticable

#access_denied, #authenticate_account, #authenticate_account!, #authenticate_account_from_login_token!, #check_is_a_manager, #check_is_a_sales_manager, #check_is_an_admin, #check_is_an_employee, #check_party, #clear_mismatched_guest_user, #create_guest_user, #credentials?, #current_or_guest_user, #current_or_guest_user_id_read_only, #current_user, #devise_mapping, #fully_logged_in?, #generate_bot_id, #guest_user, #identifiable?, #init_current_user, #initialize_guest, #load_context_user, #logging_in, #resource, #resource_name, #restrict_access_for_non_employees, #scrubbed_request_path, #user_object, #warn_on_session_guest_id_leak

Methods included from UrlsHelper

#catalog_breadcrumb_links, #catalog_link, #catalog_link_for_product_line, #catalog_link_for_sku, #cms_link, #delocalized_path, #path_to_sales_product_sku, #path_to_sales_product_sku_for_product_line, #path_to_sales_product_sku_for_product_line_slug, #product_line_from_catalog_link, #protocol_neutral_url, #sanitize_external_url, #valid_external_url?

Instance Method Details

#action_itemsvoid

This method returns an undefined value.

Lists open SEO recommendations, grouped by page path (with
locale-specific vs. shared recs split out), paginated by distinct path.



365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
# File 'app/controllers/crm/site_maps_controller.rb', line 365

def action_items
  authorize! :manage, SiteMap
  rec_scope = filtered_recommendations_scope

  paths_with_recs = rec_scope.joins(:site_map)
                             .select('site_maps.path')
                             .distinct
                             .order('site_maps.path')
                             .pluck('site_maps.path')

  @pagy, paginated_paths = pagy_paths(paths_with_recs, limit: 50)

  site_maps_for_paths = SiteMap.where(path: paginated_paths).order(:path, :locale).index_by(&:id)
  site_map_ids = site_maps_for_paths.keys

  recs = rec_scope.where(site_map_id: site_map_ids)
                  .includes(:site_map)
                  .by_priority
                  .order(created_at: :desc)

  @path_groups = build_path_groups(paginated_paths, recs)

  respond_to do |format|
    format.html
    format.turbo_stream
  end
end

#add_keywordObject

POST /site_maps/:id/add_keyword — add a keyword to this page's list (ad-hoc, not from sync)



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'app/controllers/crm/site_maps_controller.rb', line 230

def add_keyword
  authorize! :manage, SiteMap
  @site_map = SiteMap.find(params[:id])
  raw = params[:keyword].to_s.strip
  if raw.blank?
    flash[:error] = 'Keyword cannot be blank.'
    redirect_to site_map_path(@site_map, anchor: 'sm-keywords') and return
  end
  if SeoPageKeyword.noise?(raw)
    flash[:error] = 'Keyword is too short or invalid.'
    redirect_to site_map_path(@site_map, anchor: 'sm-keywords') and return
  end
  existing = @site_map.seo_page_keywords.find_by('LOWER(keyword) = ?', raw.downcase)
  if existing
    flash[:info] = "Keyword already in list."
    redirect_to site_map_path(@site_map, anchor: 'sm-keywords') and return
  end
  @site_map.seo_page_keywords.create!(keyword: raw, position: nil)
  flash[:success] = "Added \"#{raw}\" to this page's keywords."
  redirect_to site_map_path(@site_map, anchor: 'sm-keywords')
rescue ActiveRecord::RecordInvalid => e
  flash[:error] = e.record.errors.full_messages.to_sentence
  redirect_to site_map_path(@site_map, anchor: 'sm-keywords')
end

#analyzevoid

This method returns an undefined value.

Runs a full SEO analysis (including data syncs) on the page.



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'app/controllers/crm/site_maps_controller.rb', line 104

def analyze
  authorize! :manage, SiteMap
  @site_map = SiteMap.find(params[:id])

  options = { return_path: site_map_path(@site_map) }
  job_id = SeoPageAnalysisWorker.perform_async(@site_map.id, options)

  if job_id
    flash[:info] = 'SEO analysis started. You will be redirected when complete.'
    redirect_to job_path(job_id)
  else
    flash[:warning] = 'An analysis for this page is already running. Please wait for it to finish.'
    redirect_to site_map_path(@site_map)
  end
end

#analyze_onlyvoid

This method returns an undefined value.

Runs SEO analysis using existing data only, skipping GSC/GA4/keyword
syncs.



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'app/controllers/crm/site_maps_controller.rb', line 124

def analyze_only
  authorize! :manage, SiteMap
  @site_map = SiteMap.find(params[:id])

  options = { return_path: site_map_path(@site_map), skip_syncs: true }
  job_id = SeoPageAnalysisWorker.perform_async(@site_map.id, options)

  if job_id
    flash[:info] = 'AI analysis started (using existing data). You will be redirected when complete.'
    redirect_to job_path(job_id)
  else
    flash[:warning] = 'An analysis for this page is already running. Please wait for it to finish.'
    redirect_to site_map_path(@site_map)
  end
end

#analyze_premiumvoid

This method returns an undefined value.

Runs a premium-model (Opus) SEO analysis on the page.



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'app/controllers/crm/site_maps_controller.rb', line 143

def analyze_premium
  authorize! :manage, SiteMap
  @site_map = SiteMap.find(params[:id])

  options = { return_path: site_map_path(@site_map), premium: true }
  job_id = SeoPageAnalysisWorker.perform_async(@site_map.id, options)

  if job_id
    flash[:info] = 'Premium SEO analysis started (Opus). You will be redirected when complete.'
    redirect_to job_path(job_id)
  else
    flash[:warning] = 'An analysis for this page is already running. Please wait for it to finish.'
    redirect_to site_map_path(@site_map)
  end
end

#apply_suggestion_to_selectedObject

PATCH /site_maps/:id/apply_suggestion_to_selected — set keyword_target from intent-based suggestion for selected keywords



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'app/controllers/crm/site_maps_controller.rb', line 213

def apply_suggestion_to_selected
  authorize! :manage, SiteMap
  @site_map = SiteMap.find(params[:id])
  ids = Array(params[:seo_page_keyword_ids]).filter_map(&:to_i).uniq

  updated = 0
  @site_map.seo_page_keywords.where(id: ids).find_each do |pk|
    suggestion = @site_map.suggested_keyword_target_for(pk.keyword, search_volume: pk.search_volume)
    pk.update!(keyword_target: suggestion)
    updated += 1
  end

  flash[:success] = "Applied suggestion to #{updated} keyword(s)."
  redirect_to site_map_path(@site_map, anchor: 'sm-keywords')
end

#indexvoid

This method returns an undefined value.

Lists site maps with dashboard stats, recommendation counts, and ad
spend, running any queued auto-purge action.



13
14
15
16
17
18
19
20
21
22
23
24
# File 'app/controllers/crm/site_maps_controller.rb', line 13

def index
  authorize! :manage, SiteMap
  @search_form = Crm::SiteMapSearchForm.new(params)
  @search_form.ransack_search.sorts = 'updated_at desc' if @search_form.ransack_search.sorts.empty?
  @pagy, @site_maps = pagy(:countless, @search_form.results, limit: 50)

  load_index_stats
  load_recommendation_counts
  load_ads_spend

  handle_auto_purge_action if @search_form.auto_purge?
end

#keyword_lookupObject

GET /site_maps/keyword_lookup.json — TomSelect-compatible ranking keyword search



91
92
93
94
95
96
97
98
99
# File 'app/controllers/crm/site_maps_controller.rb', line 91

def keyword_lookup
  authorize! :manage, SiteMap

  scope = SeoPageKeyword.ranking_keyword_suggestions(params[:q])

  render json: TomSelect.format_json_results(self, scope, params[:page], KEYWORD_LOOKUP_LIMIT) { |seo_page_keyword|
    { id: seo_page_keyword.keyword, text: seo_page_keyword.keyword }
  }
end

#lookupObject

GET /site_maps/lookup.json — TomSelect-compatible search for sitemap paths



77
78
79
80
81
82
83
84
85
86
87
88
# File 'app/controllers/crm/site_maps_controller.rb', line 77

def lookup
  authorize! :manage, SiteMap

  term = params[:q].to_s.strip
  scope = SiteMap.active.where(locale: 'en-US')
  scope = term.present? ? scope.lookup_search(term) : scope.order(:path)

  render json: TomSelect.format_json_results(self, scope, params[:page], params[:per_page]) { |sm|
    title = sm.extracted_title.presence
    { id: sm.path, text: title ? "#{sm.path}#{title}" : sm.path }
  }
end

#performvoid

This method returns an undefined value.

Runs the selected bulk action (cache warm, edge purge, or tag purge) on
the checked site maps.



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'app/controllers/crm/site_maps_controller.rb', line 30

def perform
  authorize! :manage, SiteMap
  @search_form = Crm::SiteMapSearchForm.new(params)
  @site_maps = @search_form.results.where(id: params[:smids])

  if params[:fetch].present?
    Cache::SiteCrawler.new.process(pages: @site_maps)
    flash[:info] = 'Cached warmed up for selected urls'
    @site_maps.reload
  elsif params[:purge].present? || params[:purge_all].present?
    # Include each product page's lazy section fragments (documents/reviews/…),
    # which Cloudflare caches as separate entries — purging the page URL alone
    # leaves them stale. See SiteMap#section_cache_urls.
    urls = @site_maps.flat_map { |sm| [sm.url, *sm.section_cache_urls] }.uniq
    EdgeCacheWorker.perform_async('urls' => urls)
    flash[:info] = "Edge Cache Purge queued for #{urls.size} url(s)"
  elsif params[:purge_by_tag].present?
    purge_selected_by_tag
  else
    flash[:warning] = 'No action selected'
  end

  # Redirect (PRG) instead of render :index — a 200 render on this POST is
  # dropped by Turbo Drive, and it lands flash[:info] above on the next page.
  # Matches the sibling #regenerate.
  redirect_to_return_path_or_default site_maps_path
end

#recrawlvoid

This method returns an undefined value.

Re-crawls the page and re-extracts its rendered content/schema types.



343
344
345
346
347
348
349
350
351
# File 'app/controllers/crm/site_maps_controller.rb', line 343

def recrawl
  authorize! :manage, SiteMap
  @site_map = SiteMap.find(params[:id])
  Cache::SiteCrawler.new.process(pages: SiteMap.where(id: @site_map.id), extract_content: true)
  @site_map.reload
  schema_summary = @site_map.rendered_schema_types.join(', ').presence || 'none'
  flash[:info] = "Recrawled #{@site_map.path} — Status: #{@site_map.last_status}, Schema: #{schema_summary}"
  redirect_back_or_to site_maps_path
end

#regeneratevoid

This method returns an undefined value.

Enqueues a background job to regenerate the XML sitemap.



61
62
63
64
65
66
# File 'app/controllers/crm/site_maps_controller.rb', line 61

def regenerate
  authorize! :manage, SiteMap
  job_id = ServiceRunner.perform_async([Sitemap::SitemapGenerator.name])
  flash[:info] = "Job #{job_id} was enqued"
  redirect_to_return_path_or_default site_maps_path
end

#showvoid

This method returns an undefined value.

Shows a single site map's detail page.



71
72
73
74
# File 'app/controllers/crm/site_maps_controller.rb', line 71

def show
  authorize! :manage, SiteMap
  @site_map = SiteMap.find(params[:id])
end

#sync_ga4void

This method returns an undefined value.

Syncs the page's last-30-day Google Analytics 4 metrics.



310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'app/controllers/crm/site_maps_controller.rb', line 310

def sync_ga4
  authorize! :manage, SiteMap
  @site_map = SiteMap.find(params[:id])

  ga4 = Seo::Ga4ApiClient.new
  start_date = 30.days.ago.to_date
  end_date = Date.yesterday
  page_path = "/#{@site_map.locale}#{@site_map.path}"
  metrics = ga4.page_metrics(page_path: page_path, start_date: start_date, end_date: end_date)

  if metrics
    SiteMapDataPoint.bulk_record!(
      site_map: @site_map,
      metrics: { ga4_page_views: metrics[:screen_page_views], ga4_sessions: metrics[:sessions],
                 ga4_users: metrics[:total_users],
                 ga4_bounce_rate: (metrics[:bounce_rate].to_f * 100).round(2),
                 ga4_engagement_rate: (metrics[:engagement_rate].to_f * 100).round(2),
                 ga4_avg_session_duration: metrics[:average_session_duration]&.round(2) },
      period_start: start_date, period_end: end_date
    )
    flash[:success] = "GA4 synced — #{metrics[:screen_page_views]} page views, #{metrics[:sessions]} sessions"
  else
    flash[:info] = "No GA4 data found for #{page_path}"
  end
  redirect_back_or_to site_map_path(@site_map)
rescue StandardError => e
  flash[:error] = "GA4 sync failed: #{e.message}"
  redirect_back_or_to site_map_path(@site_map)
end

#sync_gscvoid

This method returns an undefined value.

Syncs the page's last-28-day Google Search Console metrics.



280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
# File 'app/controllers/crm/site_maps_controller.rb', line 280

def sync_gsc
  authorize! :manage, SiteMap
  @site_map = SiteMap.find(params[:id])

  gsc = Seo::McpClients.gsc
  start_date = 28.days.ago.to_date
  end_date = Date.yesterday
  metrics = gsc.page_metrics(page_url: @site_map.production_url, start_date: start_date, end_date: end_date)

  if metrics
    SiteMapDataPoint.bulk_record!(
      site_map: @site_map,
      metrics: { gsc_clicks: metrics[:clicks], gsc_impressions: metrics[:impressions],
                 gsc_ctr: metrics[:ctr], gsc_avg_position: metrics[:position] },
      period_start: start_date, period_end: end_date
    )
    @site_map.update_columns(seo_clicks: metrics[:clicks])
    flash[:success] = "GSC synced — #{metrics[:clicks]} clicks, #{metrics[:impressions]} impressions"
  else
    flash[:info] = "No GSC data found for #{@site_map.production_url}"
  end
  redirect_back_or_to site_map_path(@site_map)
rescue StandardError => e
  flash[:error] = "GSC sync failed: #{e.message}"
  redirect_back_or_to site_map_path(@site_map)
end

#sync_keywordsvoid

This method returns an undefined value.

Syncs ranking keywords for the page from GSC or Ahrefs.



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'app/controllers/crm/site_maps_controller.rb', line 162

def sync_keywords
  authorize! :manage, SiteMap
  @site_map = SiteMap.find(params[:id])

  result = Seo::KeywordSyncService.new(site_map: @site_map).process

  if result[:error]
    flash[:error] = "Keyword sync failed: #{result[:error]}"
  else
    source = result[:source] == 'gsc' ? 'GSC' : 'Ahrefs'
    flash[:success] = "Synced #{result[:keywords_synced]} keywords via #{source}"
  end

  redirect_back_or_to site_map_path(@site_map)
end

#sync_visitsvoid

This method returns an undefined value.

Syncs the page's 30-day visit counts.



258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'app/controllers/crm/site_maps_controller.rb', line 258

def sync_visits
  authorize! :manage, SiteMap
  @site_map = SiteMap.find(params[:id])

  result = Seo::VisitsSyncService.new(site_map_ids: [@site_map.id]).call
  if result[:deferred]
    flash[:warning] = 'Visit counts were not updated — the daily aggregate backfill is awaiting rollout approval.'
  elsif result[:errors].present?
    flash[:error] = "Visit sync failed: #{Array(result[:errors]).join('; ')}"
  else
    @site_map.reload
    flash[:success] = "Visit counts synced — 30d: #{@site_map.visit_count_30d || 0}"
  end
  redirect_back_or_to site_map_path(@site_map)
rescue StandardError => e
  flash[:error] = "Visit sync failed: #{e.message}"
  redirect_back_or_to site_map_path(@site_map)
end

#update_keyword_targetObject

PATCH /site_maps/:id/update_keyword_target?seo_page_keyword_id=123&keyword_target=desired



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'app/controllers/crm/site_maps_controller.rb', line 195

def update_keyword_target
  authorize! :manage, SiteMap
  @site_map = SiteMap.find(params[:id])
  pk = @site_map.seo_page_keywords.find(params.require(:seo_page_keyword_id))

  value = params[:keyword_target].to_s.strip.presence
  value = nil if value.present? && !SeoPageKeyword.keyword_targets.key?(value)

  if pk.update(keyword_target: value)
    flash[:success] = 'Keyword target updated.'
  else
    flash[:error] = pk.errors.full_messages.to_sentence
  end

  redirect_to site_map_path(@site_map, anchor: 'sm-keywords')
end

#update_seo_targetingvoid

This method returns an undefined value.

Updates the page's target SEO query.



181
182
183
184
185
186
187
188
189
190
191
192
# File 'app/controllers/crm/site_maps_controller.rb', line 181

def update_seo_targeting
  authorize! :manage, SiteMap
  @site_map = SiteMap.find(params[:id])

  if @site_map.update(seo_targeting_params)
    flash[:success] = 'Target keyword updated.'
  else
    flash[:error] = @site_map.errors.full_messages.to_sentence
  end

  redirect_back_or_to site_map_path(@site_map)
end