Class: CatalogPathResolver

Inherits:
BaseService show all
Defined in:
app/services/catalog_path_resolver.rb

Overview

Unified URL path resolver for hierarchical catalog URLs.

Replaces duplicated resolution logic previously scattered across:

  • Www::ProductsController#line / #code / #load_product
  • Www::SupportPortalsController#load_main_product_line / #product_line
  • Www::ReviewsController#product_index
  • Www::FaqsController#load_product

Given a root slug and path segments (from a wildcard route), resolves to
a product line, item, and section — or indicates a CMS fallback.

Examples:

result = CatalogPathResolver.new.resolve(root_slug: "towel-warmer", path_segments: ["barcelona", "TW-BC-08BS-FS2", "support"])
result.product_line  # => <ProductLine slug: "barcelona">
result.item          # => <Item sku: "TW-BC-08BS-FS2">
result.section       # => :support

Defined Under Namespace

Classes: Result

Constant Summary collapse

KNOWN_SECTIONS =

Known sections.

%w[support reviews faqs].to_set.freeze
PRODUCT_LINE_ROOT_SLUGS =

Root product line slugs that map to hierarchical catalog URLs.
Used by config/routes/www.rb to constrain the catalog wildcard route.

%w[
  floor-heating
  snow-melting
  towel-warmer
  infrared-heating-panels
  led-mirror
  mirror-defogger
  countertop-heater
  roof-and-gutter-deicing
  pipe-freeze-protection
  shower-kits
  rental-tools
  under-desk-heater
].freeze
PRODUCT_LINE_ROOT_REGEX =

Regex pattern matching product line root.

Regexp.union(PRODUCT_LINE_ROOT_SLUGS)
SKU_ALIASES =

SKU aliases: map non-canonical SKUs to their canonical form

{
  'UDG4-4999-WY' => 'UDG4-4999'
}.freeze

Instance Attribute Summary

Attributes inherited from BaseService

#options

Instance Method Summary collapse

Methods inherited from BaseService

#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #process, #tagged_logger

Constructor Details

This class inherits a constructor from BaseService

Instance Method Details

#build_canonical_redirect_path(record, section: nil) ⇒ Object

Build a locale-prefixed canonical redirect path for any record with canonical_path.



173
174
175
176
177
178
179
# File 'app/services/catalog_path_resolver.rb', line 173

def build_canonical_redirect_path(record, section: nil)
  path = record.canonical_path.presence
  return nil unless path

  path = "#{path}/#{section}" if section
  "/#{I18n.locale}/#{path}"
end

#product_line_for_path(root_slug:, path_segments: []) ⇒ Object

Resolve a root slug plus intermediate path segments to the deepest
matching ProductLine. friendly_id history-aware; skips non-public
intermediate PLs (same walk as #resolve). Returns nil if the root or
any segment fails to resolve.

Used by pillar-nested content URLs (e.g. /floor-heating/tempzone/videos).



245
246
247
248
249
250
251
252
253
254
255
# File 'app/services/catalog_path_resolver.rb', line 245

def product_line_for_path(root_slug:, path_segments: [])
  current_pl = find_product_line_by_slug(root_slug, parent_id: nil)

  Array(path_segments).each do |segment|
    break unless current_pl

    current_pl = find_product_line_by_slug(segment, parent_id: current_pl.id) ||
                 find_descendant_by_slug(segment, ancestor: current_pl)
  end
  current_pl
end

#resolve(root_slug:, path_segments: []) ⇒ Result

Resolve a hierarchical path to catalog entities.

Parameters:

  • root_slug (String)

    The root product line slug (e.g. "towel-warmer")

  • path_segments (Array<String>) (defaults to: [])

    Remaining path segments after root

Returns:



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'app/services/catalog_path_resolver.rb', line 79

def resolve(root_slug:, path_segments: [])
  segments = path_segments.dup

  # 1. Detect and pop section suffix
  section = extract_section(segments)

  # 2. Find root product line
  root_pl = find_product_line_by_slug(root_slug, parent_id: nil)
  return cms_fallback(root_slug, path_segments) unless root_pl

  # No additional segments — this is the root product line page
  return Result.new(product_line: root_pl, section: section) if segments.empty?

  # 3. Walk slug chain to find the deepest matching ProductLine.
  #    Track whether any segment resolved via FriendlyId history (stale slug).
  current_pl = root_pl
  consumed = 0
  has_stale_slug = (root_pl.slug != root_slug)

  segments.each_with_index do |segment, idx|
    child = find_product_line_by_slug(segment, parent_id: current_pl.id)
    # Canonical paths may skip non-public intermediate PLs, so search descendants
    child ||= find_descendant_by_slug(segment, ancestor: current_pl)
    break unless child

    has_stale_slug = true if child.slug != segment
    current_pl = child
    consumed = idx + 1
  end

  remaining = segments[consumed..]

  # 4. Remaining segments: try as SKU (must be exactly one segment)
  if remaining.present?
    return cms_fallback(root_slug, path_segments) if remaining.size > 1

    potential_sku = remaining.first
    canonical_sku = canonicalize_sku(potential_sku)

    item = find_item(canonical_sku)

    if item
      # Non-canonical path (extra segments, or wrong hierarchy) — 301 to canonical URL
      item_canonical = item.canonical_path
      request_path = ([root_slug] + segments).join('/')
      if item_canonical.present? && item_canonical != request_path
        return Result.new(
          redirect_to: build_canonical_redirect_path(item, section: section),
          product_line: item.primary_product_line,
          item: item,
          section: section
        )
      end

      vpc = load_vpc_for_item(item)

      # Successor redirect (skip for support pages — customers need support for discontinued products)
      if section != :support && vpc && !vpc.item_is_web_accessible && vpc.successor_item_sku.present?
        successor_item = Item.find_by(sku: vpc.successor_item_sku)
        successor_path = successor_item ? build_canonical_redirect_path(successor_item, section: section) : nil
        if successor_path.present?
          return Result.new(
            redirect_to: successor_path,
            error_message: "#{item.sku} is no longer available"
          )
        end
      end

      return Result.new(
        product_line: current_pl,
        item: item,
        vpc: vpc,
        section: section
      )
    end

    # Not a product line, not a SKU — fall through to CMS
    return cms_fallback(root_slug, path_segments)
  end

  # Historical slug detected on product line — 301 to canonical path
  if has_stale_slug
    return Result.new(
      redirect_to: build_canonical_redirect_path(current_pl, section: section),
      product_line: current_pl,
      section: section
    )
  end

  # Only product line segments matched
  Result.new(product_line: current_pl, section: section)
end

#resolve_legacy_product_line(legacy_url, section: nil) ⇒ Object

Resolve a legacy /products/line/:url path to a canonical redirect target.



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'app/services/catalog_path_resolver.rb', line 213

def resolve_legacy_product_line(legacy_url, section: nil)
  slug = LtreePaths.slug_ltree_from_legacy_hyphen_url(legacy_url)
  return Result.new(error_message: "Product line #{legacy_url} not found") if slug.blank?

  # Slug passed Ruby validation but may still be rejected by PostgreSQL ltree cast
  # (e.g. crawlers with URL-encoded characters that decode to valid regex but invalid ltree)
  pl = begin
    ProductLine.find_by(slug_ltree: slug)
  rescue ActiveRecord::StatementInvalid
    nil
  end
  return Result.new(error_message: "Product line #{legacy_url} not found") unless pl

  redirect_path = build_canonical_redirect_path(pl, section: section)
  if redirect_path.present?
    Result.new(redirect_to: redirect_path, product_line: pl, section: section)
  else
    Result.new(error_message: "Product line #{legacy_url} has no canonical path")
  end
end

#resolve_legacy_sku(sku, section: nil) ⇒ Object

Resolve a legacy /products/code/:sku path to a canonical redirect target.



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'app/services/catalog_path_resolver.rb', line 182

def resolve_legacy_sku(sku, section: nil)
  canonical_sku = canonicalize_sku(sku)
  item = Item.includes(:primary_product_line).find_by(sku: canonical_sku)
  return Result.new(error_message: "SKU #{sku} not found") unless item

  vpc = load_vpc_for_item(item)

  if vpc && !vpc.item_is_web_accessible && vpc.successor_item_sku.present?
    successor = Item.includes(:primary_product_line).find_by(sku: vpc.successor_item_sku)
    successor_path = successor ? build_canonical_redirect_path(successor, section: section) : nil
    return Result.new(redirect_to: successor_path, error_message: "#{sku} is no longer available") if successor_path.present?

    return Result.new(error_message: "#{sku} is no longer available")

  end

  redirect_path = build_canonical_redirect_path(item, section: section)
  if redirect_path.present?
    Result.new(
      redirect_to: redirect_path,
      product_line: item.primary_product_line,
      item: item,
      vpc: vpc,
      section: section
    )
  else
    Result.new(error_message: "#{sku} has no canonical path")
  end
end

#resolve_legacy_support_sku(sku) ⇒ Object

Resolve a legacy /support/sku/:sku path.



235
236
237
# File 'app/services/catalog_path_resolver.rb', line 235

def resolve_legacy_support_sku(sku)
  resolve_legacy_sku(sku, section: :support)
end