Class: Showcase

Inherits:
ApplicationRecord show all
Extended by:
FriendlyId
Includes:
Models::Auditable, Models::CrossLinkable, Models::Embeddable, Models::Taggable
Defined in:
app/models/showcase.rb

Overview

== Schema Information

Table name: showcases
Database name: primary

id :bigint not null, primary key
amps :integer
breadcrumbs :string default([]), not null, is an Array
breaker_size :string
city :string
connection_type :string
custom_slug :string
description :text
enabled_buttons :jsonb
floor_plan_display_ids :integer default([]), not null, is an Array
flooring_surface_types :string default([]), not null, is an Array
item_ids :integer default([]), not null, is an Array
name :string
operating_cost :decimal(10, 2)
operating_cost_explanation :text
post_ids :integer default([]), not null, is an Array
product_line_ids :integer default([]), not null, is an Array
project_type :string
quote_ids :integer default([]), not null, is an Array
room_size :string
room_types :string default([]), not null, is an Array
seo_description :text
seo_title :string
short_description :text
state :string
state_code :string
surface :string
volts :integer
wattage :integer
created_at :datetime not null
updated_at :datetime not null
creator_id :integer
customer_id :integer
floor_type_id :integer
legacy_showcase_id :integer
room_configuration_id :integer
updater_id :integer

Indexes

index_showcases_on_creator_id (creator_id)
index_showcases_on_custom_slug (custom_slug) UNIQUE
index_showcases_on_customer_id (customer_id)
index_showcases_on_enabled_buttons (enabled_buttons) USING gin
index_showcases_on_floor_plan_display_ids (floor_plan_display_ids) USING gin
index_showcases_on_floor_type_id (floor_type_id)
index_showcases_on_item_ids (item_ids) USING gin
index_showcases_on_post_ids (post_ids) USING gin
index_showcases_on_product_line_ids (product_line_ids) USING gin
index_showcases_on_quote_ids (quote_ids) USING gin
index_showcases_on_room_configuration_id (room_configuration_id)
index_showcases_on_state_code (state_code)
index_showcases_on_updater_id (updater_id)

Foreign Keys

fk_rails_... (customer_id => parties.id)
fk_rails_... (floor_type_id => floor_types.id)
fk_rails_... (room_configuration_id => room_configurations.id)

Constant Summary collapse

PROJECT_TYPES =

Project Types

%w[HO PRO].freeze
AVAILABLE_BUTTONS =
%w[
  button_design_room
  button_floor_heating_quote
  button_customize_floor_plan
  button_snow_melting_quote
  button_floor_heating
  button_snow_melting
  button_roof_gutter_deicing
  button_pipe_freeze_protection
].freeze
V2_MIGRATION_CUTOFF =

Date when showcases_v2 was renamed back to showcases (migration 20251013120000).
Versions for the current showcase ID created before this date may belong to a
different (legacy) showcase that happened to reuse the same primary key.

Time.utc(2025, 10, 13).freeze

Constants included from Models::Embeddable

Models::Embeddable::DEFAULT_MODEL, Models::Embeddable::MAX_CONTENT_LENGTH

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has many collapse

Methods included from Models::CrossLinkable

#inbound_content_links, #outbound_content_links

Methods included from Models::Taggable

#tag_records, #taggings

Methods included from Models::Embeddable

#content_embeddings

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::CrossLinkable

#content_links_count, #linked_content, #linked_posts, #linked_publications, #linked_showcases, #linked_videos

Methods included from Models::Taggable

#add_tag, all_tags, #has_tag?, normalize_tag_names, not_tagged_with, #remove_tag, #tag_list, #tag_list=, #taggable_type_for_tagging, tagged_with, #tags, #tags=, tags_cloud, tags_exclude, tags_include, with_all_tags, with_any_tags, without_all_tags, without_any_tags

Methods included from Models::Embeddable

#embeddable_locales, #embedding_content_hash, embedding_partition_class, #embedding_stale?, #embedding_type_name, #embedding_vector, #find_content_embedding, #find_similar, #generate_all_embeddings!, #generate_chunked_embeddings!, #generate_embedding!, #has_embedding?, #locale_for_embedding, #needs_chunking?, regenerate_all_embeddings, semantic_search

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransortable_attributes, #to_relation

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#custom_slugObject (readonly)



225
# File 'app/models/showcase.rb', line 225

validates :custom_slug, presence: true, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/, message: 'can only contain lowercase letters, numbers, and hyphens' }

#enabled_buttonsObject (readonly)



226
# File 'app/models/showcase.rb', line 226

validates :enabled_buttons, presence: true

#nameObject (readonly)

Validations

Validations:



223
# File 'app/models/showcase.rb', line 223

validates :name, presence: true

#stateObject (readonly)



224
# File 'app/models/showcase.rb', line 224

validates :state, presence: true

Class Method Details

.archivedActiveRecord::Relation<Showcase>

A relation of Showcases that are archived. Active Record Scope

Returns:

See Also:



257
# File 'app/models/showcase.rb', line 257

scope :archived, -> { where(state: 'archived') }

.by_product_line_idActiveRecord::Relation<Showcase>

A relation of Showcases that are by product line id. Active Record Scope

Returns:

See Also:



267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'app/models/showcase.rb', line 267

scope :by_product_line_id, ->(*product_line_ids) {
  ids = product_line_ids.flatten.map(&:presence).compact.map(&:to_i).uniq
  next all if ids.empty?

  related_ids = ids.flat_map do |pid|
    pl = ProductLine.find_by(id: pid)
    next [] unless pl

    [pl.id, pl.ancestors.pluck(:id), pl.descendants.pluck(:id)]
  end.flatten.compact.uniq

  next all if related_ids.empty?

  where('array_remove(product_line_ids, NULL) && ARRAY[?]::integer[]', related_ids)
}

.by_quote_idActiveRecord::Relation<Showcase>

A relation of Showcases that are by quote id. Active Record Scope

Returns:

See Also:



282
283
284
285
286
287
# File 'app/models/showcase.rb', line 282

scope :by_quote_id, ->(*quote_ids) {
  ids = quote_ids.flatten.map(&:presence).compact.map(&:to_i).uniq
  next all if ids.empty?

  where('array_remove(quote_ids, NULL) && ARRAY[?]::integer[]', ids)
}

.by_skuActiveRecord::Relation<Showcase>

A relation of Showcases that are by sku. Active Record Scope

Returns:

See Also:



288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'app/models/showcase.rb', line 288

scope :by_sku, ->(*skus) {
  sku_list = skus
             .flatten
             .compact
             .map { |s| s.to_s.strip }
             .flat_map { |s| s.split(/[\s,]+/) }
             .map(&:strip)
             .reject(&:blank?)
             .uniq
  next all if sku_list.empty?

  item_ids = Item.where(sku: sku_list).pluck(:id)
  next none if item_ids.empty?

  where('array_remove(item_ids, NULL) && ARRAY[?]::integer[]', item_ids)
}

.draftActiveRecord::Relation<Showcase>

A relation of Showcases that are draft. Active Record Scope

Returns:

See Also:



256
# File 'app/models/showcase.rb', line 256

scope :draft, -> { where(state: 'draft') }

.embeddable_content_typesObject

Embeddable configuration



481
482
483
# File 'app/models/showcase.rb', line 481

def self.embeddable_content_types
  %i[primary visual]
end

.filter_value_optionsObject



470
471
472
473
474
# File 'app/models/showcase.rb', line 470

def self.filter_value_options
  ['G Shaped - Peninsula', 'L Shaped', 'U Shaped', 'Single Wall or Straight Kitchen',
   'Corridor or Gallery Kitchen', 'Small', 'Medium', 'Large', '1-49 sq.ft.', '50-99 sq.ft.',
   '100-149 sq.ft.', '150-199 sq.ft.', '200+ sq.ft.', 'Shower Only', 'Shower + Bench']
end

.flooring_surface_types_includeActiveRecord::Relation<Showcase>

A relation of Showcases that are flooring surface types include. Active Record Scope

Returns:

See Also:



326
327
328
329
330
331
332
333
334
335
336
337
338
# File 'app/models/showcase.rb', line 326

scope :flooring_surface_types_include, ->(*names) {
  values = names
           .flatten
           .map { |n| n.to_s.strip.presence }
           .compact
           .uniq
  next all if values.empty?

  where(
    'ARRAY(SELECT x FROM unnest(array_remove(flooring_surface_types, NULL)) AS x) && ARRAY[?]::varchar[]',
    values
  )
}

.flush_edge_cacheObject

quick_links_hash logic is defined in ShowcasePresenter to allow using view helpers



456
457
458
459
460
461
462
# File 'app/models/showcase.rb', line 456

def self.flush_edge_cache
  return :disabled unless Cache::EdgeCacheUtility.edge_cache_enabled?

  urls = SiteMap.where(resource_type: 'Showcase', resource_id: published.select(:id)).map(&:url)
  EdgeCacheWorker.perform_async('urls' => urls) if urls.present?
  urls
end

.for_countryActiveRecord::Relation<Showcase>

A relation of Showcases that are for country. Active Record Scope

Returns:

See Also:



341
342
343
344
345
346
# File 'app/models/showcase.rb', line 341

scope :for_country, ->(country_iso) {
  iso = country_iso.to_s.upcase
  next all if iso.blank?

  joins(:region_state).merge(State.by_country_iso(iso))
}

.for_country_iso3ActiveRecord::Relation<Showcase>

A relation of Showcases that are for country iso3. Active Record Scope

Returns:

See Also:



349
350
351
352
353
354
# File 'app/models/showcase.rb', line 349

scope :for_country_iso3, ->(country_iso3) {
  iso3 = country_iso3.to_s.upcase
  next all if iso3.blank?

  joins(:region_state).where(states: { country_iso3: iso3 })
}

.for_itemActiveRecord::Relation<Showcase>

A relation of Showcases that are for item. Active Record Scope

Returns:

See Also:



308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
# File 'app/models/showcase.rb', line 308

scope :for_item, ->(item_or_id) {
  item_id = item_or_id.is_a?(Item) ? item_or_id.id : item_or_id.to_i
  next none if item_id.zero?

  # Build the quote-ids subquery as raw SQL so ARRAY() evaluates it once,
  # not per-row like a correlated EXISTS would.
  quote_ids_sql = LineItem
    .where(resource_type: 'Quote', item_id: item_id)
    .select(:resource_id).distinct.to_sql

  where(
    "array_remove(item_ids, NULL) && ARRAY[?]::integer[] " \
    "OR array_remove(quote_ids, NULL) && ARRAY(#{quote_ids_sql})::integer[]",
    [item_id]
  )
}

.homeownerActiveRecord::Relation<Showcase>

A relation of Showcases that are homeowner. Active Record Scope

Returns:

See Also:



258
# File 'app/models/showcase.rb', line 258

scope :homeowner, -> { where(project_type: 'HO') }

.order_by_imagesActiveRecord::Relation<Showcase>

A relation of Showcases that are order by images. Active Record Scope

Returns:

See Also:



359
360
361
# File 'app/models/showcase.rb', line 359

scope :order_by_images, -> {
  order(Arel.sql('(SELECT COUNT(*) FROM showcase_digital_assets WHERE showcase_digital_assets.showcase_id = showcases.id) DESC'))
}

.professionalActiveRecord::Relation<Showcase>

A relation of Showcases that are professional. Active Record Scope

Returns:

See Also:



259
# File 'app/models/showcase.rb', line 259

scope :professional, -> { where(project_type: 'PRO') }

.project_type_optionsObject



547
548
549
# File 'app/models/showcase.rb', line 547

def self.project_type_options
  [%w[Homeowner HO], %w[Professional PRO]]
end

.publishedActiveRecord::Relation<Showcase>

A relation of Showcases that are published. Active Record Scope

Returns:

See Also:



255
# File 'app/models/showcase.rb', line 255

scope :published, -> { where(state: 'published') }

.ransackable_scopes(_auth_object = nil) ⇒ Object



476
477
478
# File 'app/models/showcase.rb', line 476

def self.ransackable_scopes(_auth_object = nil)
  %i[tags_include room_types_include by_product_line_id by_quote_id by_sku for_item for_country for_country_iso3]
end

.room_types_includeActiveRecord::Relation<Showcase>

A relation of Showcases that are room types include. Active Record Scope

Returns:

See Also:



261
262
263
264
265
266
# File 'app/models/showcase.rb', line 261

scope :room_types_include, ->(*room_type_ids) {
  ids = room_type_ids.flatten.map(&:presence).compact.uniq.map(&:to_s)
  next all if ids.empty?

  where('array_remove(room_types, NULL) && ARRAY[?]::varchar[]', ids)
}

.with_main_imageActiveRecord::Relation<Showcase>

A relation of Showcases that are with main image. Active Record Scope

Returns:

See Also:



357
# File 'app/models/showcase.rb', line 357

scope :with_main_image, -> { includes(showcase_digital_assets: :digital_asset) }

Instance Method Details



427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
# File 'app/models/showcase.rb', line 427

def breadcrumbs_hash
  bc = []
  # Only use the first breadcrumb if multiple exist
  first_breadcrumb = (breadcrumbs || []).first
  return bc unless first_breadcrumb.present?

  begin
    parts = first_breadcrumb.split('@')
    path_url = nil
    path_name = nil
    if parts.size == 2
      path_name, path_url = parts
    else
      path_url = first_breadcrumb
      path_name = first_breadcrumb.split('/').last.to_s.scan(/\w+/).join(' ').humanize
    end
    bc << { url: "/#{I18n.locale}/#{path_url}".squeeze('/'), name: path_name }
  rescue StandardError
    Rails.logger.error 'Error in Showcase#breadcrumbs_hash path formatting'
  end
  if Rails.application.routes.url_helpers.respond_to?(:floor_plans_path)
    floor_plans_url = Rails.application.routes.url_helpers.floor_plans_path(locale: I18n.locale)
    bc << { name: 'Floor Plans', url: floor_plans_url }
  end
  bc
end

#button_display_name(button_name) ⇒ Object



413
414
415
416
417
418
419
420
421
422
423
424
425
# File 'app/models/showcase.rb', line 413

def button_display_name(button_name)
  case button_name
  when 'button_design_room' then 'Design Room'
  when 'button_floor_heating_quote' then 'Floor Heating Quote'
  when 'button_customize_floor_plan' then 'Customize Floor Plan'
  when 'button_snow_melting_quote' then 'Snow Melting Quote'
  when 'button_floor_heating' then 'Floor Heating'
  when 'button_snow_melting' then 'Snow Melting'
  when 'button_roof_gutter_deicing' then 'Roof Gutter Deicing'
  when 'button_pipe_freeze_protection' then 'Pipe Freeze Protection'
  else button_name.humanize
  end
end

#button_enabled?(button_name) ⇒ Boolean

Returns:

  • (Boolean)


397
398
399
# File 'app/models/showcase.rb', line 397

def button_enabled?(button_name)
  enabled_buttons.include?(button_name)
end

#content_for_embedding(content_type = :primary) ⇒ Object



485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
# File 'app/models/showcase.rb', line 485

def content_for_embedding(content_type = :primary)
  case content_type.to_sym
  when :primary
    parts = []

    # Core content
    parts << "Project: #{name}" if name.present?
    parts << "Location: #{city}, #{state}" if city.present? || state.present?
    parts << description if description.present?
    parts << seo_description if seo_description.present?
    parts << short_description if short_description.present?

    # Product context - crucial for finding showcases by product type
    pl_names = product_line_names_for_embedding
    parts << "Product Lines: #{pl_names}" if pl_names.present?

    # Room and surface context
    parts << "Room Types: #{room_types.join(', ')}" if room_types.present?
    parts << "Flooring Types: #{flooring_surface_types.join(', ')}" if flooring_surface_types.present?

    # FAQs provide additional context
    if faqs.any?
      faq_text = faqs.map { |f| "Q: #{f.question}\nA: #{f.answer}" }.join("\n\n")
      parts << "FAQs:\n#{faq_text}"
    end

    parts << "Tags: #{tags.join(', ')}" if tags.present?

    parts.compact.join("\n\n")
  when :visual
    # For visual search - describe what's shown in the images
    parts = []
    parts << "Project: #{name}" if name.present?
    parts << "Location: #{city}, #{state}" if city.present? || state.present?

    pl_names = product_line_names_for_embedding
    parts << "Product Lines: #{pl_names}" if pl_names.present?

    parts << "Image: #{main_image.title}" if main_image&.title.present?
    parts << main_image.meta_description if main_image&.meta_description.present?
    parts << "Image Tags: #{main_image.tags.join(', ')}" if main_image&.tags.present?

    parts.compact.join("\n\n")
  end
end

#customerCustomer

Associations

Returns:

See Also:



214
# File 'app/models/showcase.rb', line 214

belongs_to :customer, optional: true

#digital_assetsActiveRecord::Relation<DigitalAsset>

Returns:

See Also:



217
# File 'app/models/showcase.rb', line 217

has_many :digital_assets, through: :showcase_digital_assets

#disable_button(button_name) ⇒ Object



409
410
411
# File 'app/models/showcase.rb', line 409

def disable_button(button_name)
  self.enabled_buttons = enabled_buttons - [button_name]
end

#enable_button(button_name) ⇒ Object

Note: all_tags method provided by Models::Taggable concern



403
404
405
406
407
# File 'app/models/showcase.rb', line 403

def enable_button(button_name)
  return false unless AVAILABLE_BUTTONS.include?(button_name)

  self.enabled_buttons = (enabled_buttons + [button_name]).uniq
end

#faqsActiveRecord::Relation<ShowcaseFaq>

Returns:

See Also:



218
# File 'app/models/showcase.rb', line 218

has_many :faqs, class_name: 'ShowcaseFaq', dependent: :destroy

#homeowner?Boolean

Project type helpers

Returns:

  • (Boolean)


532
533
534
# File 'app/models/showcase.rb', line 532

def homeowner?
  project_type == 'HO'
end

#main_imageObject

Methods



364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
# File 'app/models/showcase.rb', line 364

def main_image
  # Use already-loaded association if available (avoids N+1 when using .with_main_image scope)
  loaded_assets = if showcase_digital_assets.loaded?
                    showcase_digital_assets.to_a
                  else
                    showcase_digital_assets.includes(:digital_asset).to_a
                  end

  # Prefer explicitly marked main image
  marked_main = loaded_assets.find { |sda| sda.is_main && sda.digital_asset&.type == 'Image' }
  return marked_main.digital_asset if marked_main

  # Fallback to first image by position
  loaded_assets.sort_by { |sda| sda.position || Float::INFINITY }
               .map(&:digital_asset)
               .find { |da| da&.type == 'Image' }
end

#professional?Boolean

Returns:

  • (Boolean)


536
537
538
# File 'app/models/showcase.rb', line 536

def professional?
  project_type == 'PRO'
end

#project_type_labelObject



540
541
542
543
544
545
# File 'app/models/showcase.rb', line 540

def project_type_label
  case project_type
  when 'HO' then 'Homeowner'
  when 'PRO' then 'Professional'
  end
end

#purge_edge_cacheObject



464
465
466
467
468
# File 'app/models/showcase.rb', line 464

def purge_edge_cache
  urls = site_maps.map(&:url)
  EdgeCacheWorker.perform_async('urls' => urls) if urls.present?
  urls
end

#region_stateState

Returns:

See Also:



215
# File 'app/models/showcase.rb', line 215

belongs_to :region_state, class_name: 'State', foreign_key: 'state_code', primary_key: 'code', optional: true

#showcase_digital_assetsActiveRecord::Relation<ShowcaseDigitalAsset>

Returns:

See Also:



216
# File 'app/models/showcase.rb', line 216

has_many :showcase_digital_assets, dependent: :destroy

#site_mapsActiveRecord::Relation<SiteMap>

Returns:

  • (ActiveRecord::Relation<SiteMap>)

See Also:



219
# File 'app/models/showcase.rb', line 219

has_many :site_maps, as: :resource, dependent: :nullify

#to_paramObject



382
383
384
# File 'app/models/showcase.rb', line 382

def to_param
  custom_slug
end

#versions_for_audit_trail(_params = {}) ⇒ Object

Combine audit trail versions from both the current showcase ID and, when present,
the legacy showcase ID that was used before the v2 migration. Excludes:

  • versions for the current ID recorded before the v2 migration (wrong showcase)
  • versions containing old singular schema fields (room_type_id, product_line_id)


560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
# File 'app/models/showcase.rb', line 560

def versions_for_audit_trail(_params = {})
  base_scope = if legacy_showcase_id.present? && legacy_showcase_id != id
                 # This showcase was migrated from showcase_v2 and received a new ID.
                 # The real historical versions are stored under the legacy ID.
                 RecordVersion.where(item_type: 'Showcase').where(
                   '(item_id = :legacy_id) OR (item_id = :current_id AND created_at >= :cutoff)',
                   legacy_id: legacy_showcase_id,
                   current_id: id,
                   cutoff: V2_MIGRATION_CUTOFF
                 )
               else
                 # No legacy mapping (or same ID) — simple filter by created_at
                 scope = versions.where(item_type: 'Showcase', item_id: id)
                 scope = scope.where('created_at >= ?', created_at) if created_at.present?
                 scope
               end

  # Exclude versions with old singular schema fields that no longer exist on the model
  base_scope.where(
    "(object_changes IS NULL OR (NOT (object_changes ? 'room_type_id') AND NOT (object_changes ? 'product_line_id')))"
  )
end