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 =
%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
  radiant-panel
  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 =
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 Method Summary collapse

Methods inherited from BaseService

#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #options, #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.



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

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

#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:



76
77
78
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
# File 'app/services/catalog_path_resolver.rb', line 76

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)
    if child
      has_stale_slug = true if child.slug != segment
      current_pl = child
      consumed = idx + 1
    else
      break
    end
  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.



181
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 181

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
    if successor_path.present?
      return Result.new(redirect_to: successor_path, error_message: "#{sku} is no longer available")
    else
      return Result.new(error_message: "#{sku} is no longer available")
    end
  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