Class: ReviewsIo

Inherits:
ApplicationRecord show all
Includes:
Models::Embeddable
Defined in:
app/models/reviews_io.rb

Overview

ReviewsIo model stores product reviews imported from Reviews.io API.
Also provides a unified interface for fetching review data (to be deprecated).

Table: reviews_io (singular name to match company branding)

Usage for stored reviews:
ReviewsIo.active.where(sku: 'PROD-123')
ReviewsIo.product_reviews.where(rating: 5)

Legacy API methods (to be deprecated):
ReviewsIo.fetch_reviews(sku: 'PROD-123', page: 1, limit: 5)
ReviewsIo.fetch_reviews_for_item(item, page: 1, limit: 10)
== Schema Information

Table name: reviews_io
Database name: primary

id :bigint not null, primary key
author_email :string
author_location :string
author_name :string
comments :text
content_changed_at :datetime
custom_headline :string
deleted_at :datetime
hidden :boolean default(FALSE), not null
last_seen_at :datetime
photos :jsonb
rating :integer
raw_api_data :jsonb
replies :jsonb
review_attributes :jsonb
review_date :datetime
review_type :string
sku :string
status :string default("active"), not null
tags :jsonb
title :string
verified_buyer :boolean default(FALSE), not null
videos :jsonb
created_at :datetime not null
updated_at :datetime not null
avatar_image_id :integer
invitation_id :string
order_id :string
reviews_io_id :string not null
reviewsio_user_id :string
store_branch_id :string

Indexes

index_reviews_io_on_avatar_image_id (avatar_image_id)
index_reviews_io_on_last_seen_at (last_seen_at)
index_reviews_io_on_photos_gin (photos) USING gin
index_reviews_io_on_rating (rating)
index_reviews_io_on_review_type (review_type)
index_reviews_io_on_reviews_io_id (reviews_io_id) UNIQUE
index_reviews_io_on_sku (sku)
index_reviews_io_on_status (status)
index_reviews_io_on_verified_buyer (verified_buyer)
index_reviews_io_on_videos_gin (videos) USING gin

Foreign Keys

fk_rails_... (avatar_image_id => digital_assets.id)

Constant Summary collapse

STATUS_ACTIVE =

Statuses

'active'
STATUS_DELETED =
'deleted'
EXTRA_CANONICAL_TAGS =

Additional canonical tags that should always appear in the CRM tag picker
and are not derived from CMS page ids (e.g. "for-homepage" has no page file).

%w[for-homepage].freeze
AVATAR_AI_WARDROBE_HINTS =

Rotating wardrobe cues for Gemini avatar prompts — reduces same-outfit bias when
+variety_seed+ (e.g. review id) is passed from +avatar_ai_image_prompt+.

[
  'soft crew-neck sweater in a solid muted color',
  'simple cotton tee with an open casual jacket',
  'light knit cardigan over a plain top',
  'clean neckline, solid-tone blouse or woven shirt, minimal accessories',
  'casual quarter-zip or pullover in a flat color, no patterns',
  'relaxed hoodie in one flat color',
  'modern understated collared shirt, smart-casual',
  'lightweight solid-color top suited to mild or warm weather',
  'fine-gauge knit in a neutral palette',
  'plain shirt with a simple vest or light outer layer if outdoors'
].freeze
AVATAR_AI_AGE_PRESENTATION_HINTS =

Rotating apparent-age cues (uncorrelated from wardrobe via different seed mixing).

[
  'apparent age in their 20s or early 30s',
  'apparent age in their mid-30s to 40s',
  'apparent age in their 50s',
  'apparent age 60 or older'
].freeze
AVATAR_AI_HERITAGE_PRESENTATION_HINTS =

Rotating respectful heritage / presentation cues — widens ethnicity and skin-tone variety.

[
  'a person whose features and skin tone read as Black or of African diaspora heritage',
  'a person whose features and skin tone read as East Asian or Southeast Asian heritage',
  'a person whose features and skin tone read as South Asian heritage',
  'a person whose features and skin tone read as Latino or Hispanic heritage',
  'a person whose features and skin tone read as Middle Eastern, North African, or Southwest Asian heritage',
  'a person whose features and skin tone read as Indigenous to the Americas',
  'a person whose features and skin tone read as Pacific Islander heritage',
  'a person whose features and skin tone read as white or of European heritage',
  'a person with multiracial or ethnically blended features'
].freeze
CRM_TAG_PREFIX =

Tags with this prefix are managed in the CRM (page-targeting, homepage, etc.)
and must be preserved during API syncs — the Reviews.io API is unaware of them.

'for-'

Constants included from Models::Embeddable

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

Instance Attribute Summary collapse

Has many collapse

Methods included from Models::Embeddable

#content_embeddings

Belongs to collapse

Class Method Summary collapse

Instance Method Summary collapse

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 inherited from ApplicationRecord

ransackable_associations, ransortable_attributes, #to_relation

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#reviews_io_idObject (readonly)

Validations

Validations:



400
# File 'app/models/reviews_io.rb', line 400

validates :reviews_io_id, presence: true, uniqueness: true

#statusObject (readonly)



401
# File 'app/models/reviews_io.rb', line 401

validates :status, presence: true, inclusion: { in: [STATUS_ACTIVE, STATUS_DELETED] }

Class Method Details

.activeActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are active. Active Record Scope

Returns:

See Also:



130
# File 'app/models/reviews_io.rb', line 130

scope :active,   -> { where(status: STATUS_ACTIVE) }

.all_tagsArray<String>

Returns all unique tags for the CRM tag picker:
• Page-targeting tags derived from CMS pages (filesystem walk, fast, constant per process).
• Extra canonical tags not derivable from page ids (e.g. "for-homepage").
• Any ad-hoc tags already assigned to reviews in the DB.
Page tags sort first; ad-hoc DB tags follow alphabetically.

Returns:

  • (Array<String>)

    deduplicated, sorted array of tags



184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'app/models/reviews_io.rb', line 184

def self.all_tags
  page_tags = DigitalAsset.available_page_tags + EXTRA_CANONICAL_TAGS

  sql = <<-SQL.squish
    SELECT DISTINCT tag
    FROM reviews_io, jsonb_array_elements_text(tags) AS tag
    WHERE tags IS NOT NULL AND jsonb_array_length(tags) > 0
    ORDER BY tag
  SQL
  db_tags = connection.select_values(sql)

  (page_tags + db_tags).uniq.sort_by { |t| [page_tags.include?(t) ? 0 : 1, t] }
end

.avatar_ai_image_prompt(name:, profile_name: nil, location: nil, variety_seed: nil) ⇒ Object

Full prompt for CRM "Generate Avatar" and +ReviewAvatarBackfillWorker+.
+variety_seed+ (e.g. +review.id+) picks a rotating wardrobe hint so batches are less samey.



590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
# File 'app/models/reviews_io.rb', line 590

def self.avatar_ai_image_prompt(name:, profile_name: nil, location: nil, variety_seed: nil)
  wardrobe_line = age_line = heritage_line = nil
  if variety_seed.present?
    s = variety_seed.to_i.abs
    wardrobe_line =
      "For this portrait specifically, use: #{AVATAR_AI_WARDROBE_HINTS[s % AVATAR_AI_WARDROBE_HINTS.size]}."
    age_line =
      "For this portrait, aim for #{AVATAR_AI_AGE_PRESENTATION_HINTS[(s / 3) % AVATAR_AI_AGE_PRESENTATION_HINTS.size]}."
    heritage_line =
      "Depict #{AVATAR_AI_HERITAGE_PRESENTATION_HINTS[(s / 7) % AVATAR_AI_HERITAGE_PRESENTATION_HINTS.size]}" \
      'authentic and respectful, never caricature or stereotype.'
  end

  [
    "Photorealistic portrait-style avatar of a person named #{name}.",
    profile_name.present? ? "They are a #{profile_name}." : nil,
    location.present? ? "They are located in #{location}." : nil,
    'Warm, friendly expression. Square crop, centered face.',
    'Across different avatars, vary ethnicity, skin tone, and age — do not default every subject to the same ' \
    'middle-aged white presentation.',
    'Vary clothing and styling — avoid repeating the same outfit archetype across different people.',
    'Do not default homeowners (or anyone) to plaid, flannel, buffalo check, or rustic ' \
    '"DIY weekend" stereotypes unless the setting clearly calls for cold-weather outdoor or trade workwear.',
    'Prefer contemporary everyday clothes: solid colors or subtle textures; casual or smart-casual; not costume-like.',
    age_line,
    heritage_line,
    wardrobe_line,
    'Choose a background that fits their profession or lifestyle rather than a generic blank interior.',
    'Be creative: a trade professional might be in a workshop or on a job site; a homeowner might be in a garden, ' \
    'on a porch, in a sunlit kitchen, at a café patio, walking a neighborhood street, or a calm modern living space — ' \
    'not the same cosy living-room cliché every time.',
    'Photography style, soft natural lighting, high detail. No text, no watermarks.'
  ].compact.join(' ')
end

.batch_stats_by_sku(skus) ⇒ Hash

Get review stats per SKU in a single query (batch method)

Parameters:

  • skus (Array<String>)

    Array of SKUs

Returns:

  • (Hash)

    { sku => { star_avg: Float, num: Integer } }



357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
# File 'app/models/reviews_io.rb', line 357

def self.batch_stats_by_sku(skus)
  return {} if skus.blank?

  # Single GROUP BY query to get per-SKU stats
  results = active
    .visible
    .product_reviews
    .by_skus(skus)
    .group(:sku)
    .pluck(:sku, Arel.sql('AVG(rating)'), Arel.sql('COUNT(*)'))

  # Convert to hash keyed by SKU
  results.each_with_object({}) do |(sku, avg, count), hash|
    hash[sku] = { star_avg: avg&.round(2), num: count.to_i }
  end
end

.by_ratingActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are by rating. Active Record Scope

Returns:

See Also:



139
# File 'app/models/reviews_io.rb', line 139

scope :by_rating, ->(rating) { where(rating: rating) }

.by_skuActiveRecord::Relation<ReviewsIo>

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

Returns:

See Also:



137
# File 'app/models/reviews_io.rb', line 137

scope :by_sku, ->(sku) { where(sku: sku) }

.by_skusActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are by skus. Active Record Scope

Returns:

See Also:



138
# File 'app/models/reviews_io.rb', line 138

scope :by_skus, ->(skus) { where(sku: skus) }

.by_tagActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are by tag. Active Record Scope

Returns:

See Also:



145
# File 'app/models/reviews_io.rb', line 145

scope :by_tag, ->(tag) { where('tags @> ?', [tag].to_json) if tag.present? }

.company_wide_statsHash

Company-wide stats across all visible reviews (store + product).
Cached 7 days — used for trust badges on landing page heroes and homepage benefits.

Returns:

  • (Hash)

    { star_avg: Float, num: Integer, satisfaction_pct: Float }



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

def self.company_wide_stats
  Rails.cache.fetch('reviews_io/company_wide_stats/v2', expires_in: 7.days) do
    scope = active.visible
    total = scope.count
    {
      star_avg: scope.average(:rating)&.round(2),
      num: total,
      satisfaction_pct: if total.positive?
                          (scope.where('rating >= 4').count * 100.0 / total).round(1)
                        end
    }
  end
end

.configured?Boolean

Check if Reviews.io is configured and available

Returns:

  • (Boolean)

    true if Reviews.io credentials are present



839
840
841
# File 'app/models/reviews_io.rb', line 839

def configured?
  store_id.present? && api_key.present?
end

.deletedActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are deleted. Active Record Scope

Returns:

See Also:



134
# File 'app/models/reviews_io.rb', line 134

scope :deleted, -> { where(status: STATUS_DELETED) }

.embeddableActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are embeddable. Active Record Scope

Returns:

See Also:



133
# File 'app/models/reviews_io.rb', line 133

scope :embeddable, -> { active.visible.product_reviews.with_comments.high_rated }

.embeddable_content_typesObject

Content types that can be embedded



439
440
441
# File 'app/models/reviews_io.rb', line 439

def self.embeddable_content_types
  [:primary]
end

.exclude_tagsActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are exclude tags. Active Record Scope

Returns:

See Also:



152
153
154
155
156
157
# File 'app/models/reviews_io.rb', line 152

scope :exclude_tags, ->(*tag_names) {
  tags = [tag_names].flatten.map(&:presence).compact
  return all if tags.empty?

  where.not("tags ?| ARRAY[#{tags.map { |t| connection.quote(t) }.join(', ')}]")
}

.fetch_reviews(sku:, page: 1, limit: 5, order: 'date_desc', include_item_name: false) ⇒ Hash

Fetch reviews for a specific SKU from the local database

Parameters:

  • sku (String)

    Product SKU

  • page (Integer) (defaults to: 1)

    Page number (default: 1)

  • limit (Integer) (defaults to: 5)

    Results per page (default: 5)

  • order (String) (defaults to: 'date_desc')

    Sort order - 'date_desc', 'date_asc', 'rating_desc', 'rating_asc' (default: 'date_desc')

  • include_item_name (Boolean) (defaults to: false)

    Whether to look up item name (default: false to avoid N+1)

Returns:

  • (Hash)

    Hash containing reviews, ratings, and metadata



696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
# File 'app/models/reviews_io.rb', line 696

def fetch_reviews(sku:, page: 1, limit: 5, order: 'date_desc', include_item_name: false)
  return empty_result(sku) if sku.blank?

  # Query from database (respect CRM / sync hiding)
  scope = active.visible.product_reviews.by_sku(sku)

  # Apply ordering
  scope = case order
          when 'rating_desc' then scope.order(rating: :desc, review_date: :desc)
          when 'rating_asc' then scope.order(rating: :asc, review_date: :desc)
          when 'date_asc' then scope.order(review_date: :asc)
          else scope.order(review_date: :desc)
          end

  # Get stats before pagination
  stats = stats_for_skus([sku])

  # Apply pagination
  offset = (page - 1) * limit
  reviews = scope.limit(limit).offset(offset).to_a

  # Only look up item name if explicitly requested (to avoid N+1 in loops)
  item_name = include_item_name ? Item.find_by(sku: sku)&.name : nil

  {
    reviews: reviews,
    star_avg: stats[:star_avg],
    num: stats[:num],
    name: item_name,
    item_sku: sku,
    updated_at: reviews.first&.updated_at || Time.current
  }
end

.fetch_reviews_by_tag(tag:, page: 1, limit: 5, order: 'date_desc') ⇒ Hash

Fetch reviews by tag from the local database

Parameters:

  • tag (String)

    Review tag (e.g., 'for-homepage')

  • page (Integer) (defaults to: 1)

    Page number (default: 1)

  • limit (Integer) (defaults to: 5)

    Results per page (default: 5)

  • order (String) (defaults to: 'date_desc')

    Sort order (default: 'date_desc')

Returns:

  • (Hash)

    Hash containing reviews, ratings, and metadata



796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
# File 'app/models/reviews_io.rb', line 796

def fetch_reviews_by_tag(tag:, page: 1, limit: 5, order: 'date_desc')
  return empty_result(nil) if tag.blank?

  # Query from database by tag (respect CRM / sync hiding)
  scope = active.visible.by_tag(tag)

  # Apply ordering
  scope = case order
          when 'rating_desc' then scope.order(rating: :desc, review_date: :desc)
          when 'rating_asc' then scope.order(rating: :asc, review_date: :desc)
          when 'date_asc' then scope.order(review_date: :asc)
          else scope.order(review_date: :desc)
          end

  # Get stats
  total_count = scope.count
  avg_rating = scope.average(:rating)&.round(2)

  # Apply pagination
  offset = (page - 1) * limit
  reviews = scope.limit(limit).offset(offset).to_a

  {
    reviews: reviews,
    star_avg: avg_rating,
    num: total_count,
    name: nil,
    item_sku: nil,
    updated_at: reviews.first&.updated_at || Time.current
  }
end

.fetch_reviews_for_item(item, page: 1, limit: 5, order: 'date_desc') ⇒ Hash

Fetch reviews for an Item instance

Parameters:

  • item (Item)

    Item model instance

  • page (Integer) (defaults to: 1)

    Page number (default: 1)

  • limit (Integer) (defaults to: 5)

    Results per page (default: 5)

  • order (String) (defaults to: 'date_desc')

    Sort order (default: 'date_desc')

Returns:

  • (Hash)

    Review data hash



737
738
739
740
741
742
743
744
745
746
# File 'app/models/reviews_io.rb', line 737

def fetch_reviews_for_item(item, page: 1, limit: 5, order: 'date_desc')
  return empty_result(nil) unless item&.sku.present?

  fetch_reviews(
    sku: item.sku,
    page: page,
    limit: limit,
    order: order
  )
end

.fetch_reviews_for_skus(skus, page: 1, limit: 5, order: 'date_desc') ⇒ Hash

Fetch reviews for multiple SKUs (useful for product line pages)

Parameters:

  • skus (Array<String>)

    Array of product SKUs

  • page (Integer) (defaults to: 1)

    Page number (default: 1)

  • limit (Integer) (defaults to: 5)

    Results per page (default: 5)

  • order (String) (defaults to: 'date_desc')

    Sort order (default: 'date_desc')

Returns:

  • (Hash)

    Aggregated review data hash



755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
# File 'app/models/reviews_io.rb', line 755

def fetch_reviews_for_skus(skus, page: 1, limit: 5, order: 'date_desc')
  return empty_result(nil) if skus.blank?

  skus_array = Array(skus).compact
  return empty_result(nil) if skus_array.empty?

  # Query from database for all SKUs (respect CRM / sync hiding)
  scope = active.visible.product_reviews.by_skus(skus_array)

  # Apply ordering
  scope = case order
          when 'rating_desc' then scope.order(rating: :desc, review_date: :desc)
          when 'rating_asc' then scope.order(rating: :asc, review_date: :desc)
          when 'date_asc' then scope.order(review_date: :asc)
          else scope.order(review_date: :desc)
          end

  # Get stats before pagination
  stats = stats_for_skus(skus_array)

  # Apply pagination
  offset = (page - 1) * limit
  reviews = scope.limit(limit).offset(offset).to_a

  {
    reviews: reviews,
    star_avg: stats[:star_avg],
    num: stats[:num],
    name: nil,
    item_sku: skus_array.first,
    updated_at: reviews.first&.updated_at || Time.current
  }
end

.hidden_reviewsActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are hidden reviews. Active Record Scope

Returns:

See Also:



132
# File 'app/models/reviews_io.rb', line 132

scope :hidden_reviews, -> { where(hidden: true) }

.high_ratedActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are high rated. Active Record Scope

Returns:

See Also:



140
# File 'app/models/reviews_io.rb', line 140

scope :high_rated, -> { where('rating >= ?', 4) }

.mark_stale_as_deleted(import_time) ⇒ Integer

Mark reviews not seen in this import as deleted

Parameters:

  • import_time (Time)

    The timestamp when import started

Returns:

  • (Integer)

    Number of reviews marked as deleted



677
678
679
680
681
# File 'app/models/reviews_io.rb', line 677

def mark_stale_as_deleted(import_time)
  active
    .where('last_seen_at < ? OR last_seen_at IS NULL', import_time)
    .update_all(status: STATUS_DELETED, deleted_at: Time.current)
end

.merge_tags_with_crm(existing_tags, api_tags) ⇒ Array<String>

Merge API-sourced tags with CRM-managed tags already on the record.
CRM tags (prefixed "for-") are preserved; API tags are fully replaced.

Parameters:

  • existing_tags (Array<String>)

    Current tags on the record (may be nil)

  • api_tags (Array<String>)

    Tags from the Reviews.io API response

Returns:

  • (Array<String>)

    Deduplicated merged tag list



633
634
635
636
# File 'app/models/reviews_io.rb', line 633

def merge_tags_with_crm(existing_tags, api_tags)
  crm_tags = Array(existing_tags).select { |t| t.start_with?(CRM_TAG_PREFIX) }
  (crm_tags + Array(api_tags)).uniq
end

.needs_syncActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are needs sync. Active Record Scope

Returns:

See Also:



142
# File 'app/models/reviews_io.rb', line 142

scope :needs_sync, -> { where('last_seen_at < ?', 25.hours.ago) }

.product_reviewsActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are product reviews. Active Record Scope

Returns:

See Also:



135
# File 'app/models/reviews_io.rb', line 135

scope :product_reviews, -> { where(review_type: 'product_review') }

.ransackable_attributes(_auth_object = nil) ⇒ Object

Ransack configuration



170
171
172
# File 'app/models/reviews_io.rb', line 170

def self.ransackable_attributes(_auth_object = nil)
  %w[author_name comments hidden id order_id rating review_date review_type sku status title verified_buyer]
end

.ransackable_scopes(_auth_object = nil) ⇒ Object



174
175
176
# File 'app/models/reviews_io.rb', line 174

def self.ransackable_scopes(_auth_object = nil)
  %i[by_tag tags_include exclude_tags with_photos with_imported_images]
end

.rating_snapshot(sku) ⇒ Hash

Get rating snapshot for a SKU (average rating and count only)

Parameters:

  • sku (String)

    Product SKU

Returns:

  • (Hash)

    Hash with :average, :count, :source



832
833
834
# File 'app/models/reviews_io.rb', line 832

def rating_snapshot(sku)
  ::Reviews::RatingSnapshot.for_sku(sku)
end

.recentActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are recent. Active Record Scope

Returns:

See Also:



141
# File 'app/models/reviews_io.rb', line 141

scope :recent, -> { order(review_date: :desc) }

.reviews_for_product_line(product_line, page: 1, per_page: nil, min_length: nil) ⇒ Hash

Get full review data for a product line (compatible with Rating class)

Parameters:

  • product_line (String, Integer, ProductLine)

    Product line URL, ID, or object

  • page (Integer) (defaults to: 1)

    Page number (default: 1)

  • per_page (Integer) (defaults to: nil)

    Results per page (default: nil for all)

  • min_length (Integer) (defaults to: nil)

    Minimum comment length filter (optional)

Returns:

  • (Hash)

    { star_avg: Float, num: Integer, reviews: Array, updated_at: Time }



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'app/models/reviews_io.rb', line 299

def self.reviews_for_product_line(product_line, page: 1, per_page: nil, min_length: nil)
  skus = skus_for_product_line(product_line)
  return { star_avg: nil, num: 0, reviews: [], updated_at: Time.current } if skus.blank?

  base_scope = active.visible.product_reviews.by_skus(skus)
  stats_scope = base_scope

  # Apply min_length filter for fetching reviews
  reviews_scope = base_scope.recent
  reviews_scope = reviews_scope.where('LENGTH(comments) >= ?', min_length) if min_length.present?

  # Apply pagination
  if per_page.present?
    offset = (page - 1) * per_page
    reviews_scope = reviews_scope.limit(per_page).offset(offset)
  end

  {
    star_avg: stats_scope.average(:rating)&.round(2),
    num: stats_scope.count,
    reviews: reviews_scope.to_a,
    updated_at: base_scope.maximum(:updated_at) || Time.current
  }
end

.skus_for_product_line(product_line) ⇒ Array<String>

Get SKUs for a product line

Parameters:

  • product_line (String, Integer, ProductLine)

    Product line legacy_url, ID, or object

Returns:

  • (Array<String>)

    Array of SKUs



377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
# File 'app/models/reviews_io.rb', line 377

def self.skus_for_product_line(product_line)
  pl = case product_line
       when ProductLine then product_line
       when Integer then ProductLine.find_by(id: product_line)
       when String then ProductLine.find_by(slug_ltree: LtreePaths.slug_ltree_from_legacy_hyphen_url(product_line)) || ProductLine.find_by(slug_ltree: product_line) || ProductLine.find_by(id: product_line)
       end
  return [] unless pl

  # Use ltree path directly on items (no separate ProductLine query needed)
  # This uses the GiST-indexed primary_pl_path_ids column on items
  if pl.ltree_path_ids.present?
    Item.where(Item[:primary_pl_path_ids].ltree_descendant(pl.ltree_path_ids))
        .pluck(:sku)
        .compact_blank
  else
    # Fallback for product lines without ltree paths
    Item.where(primary_product_line_id: ProductLine.self_and_descendants_ids(pl.id))
        .pluck(:sku)
        .compact_blank
  end
end

.stats_for_product_line(product_line) ⇒ Hash

Get review stats (average rating and count) for a product line

Parameters:

  • product_line (String, Integer, ProductLine)

    Product line URL, ID, or object

Returns:

  • (Hash)

    { star_avg: Float, num: Integer }



211
212
213
214
215
216
217
218
219
220
# File 'app/models/reviews_io.rb', line 211

def self.stats_for_product_line(product_line)
  skus = skus_for_product_line(product_line)
  return { star_avg: nil, num: 0 } if skus.blank?

  scope = active.visible.product_reviews.by_skus(skus)
  {
    star_avg: scope.average(:rating)&.round(2),
    num: scope.count
  }
end

.stats_for_product_lines(product_lines) ⇒ Hash

Batch method: Get review stats for multiple product lines in fewer queries

Parameters:

  • product_lines (Array<ProductLine>)

    Array of ProductLine objects

Returns:

  • (Hash)

    { pl_id => { star_avg: Float, num: Integer } }



225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'app/models/reviews_io.rb', line 225

def self.stats_for_product_lines(product_lines)
  pls = Array(product_lines).compact
  return {} if pls.empty?

  # Get all ltree paths
  paths_by_id = pls.each_with_object({}) do |pl, hash|
    hash[pl.id] = pl.ltree_path_ids if pl.ltree_path_ids.present?
  end

  return pls.index_with { { star_avg: nil, num: 0 } } if paths_by_id.empty?

  # Single query to get all SKUs with their ltree paths
  items_data = Item.where(Item[:primary_pl_path_ids].ltree_descendant(paths_by_id.values))
                   .pluck(:sku, :primary_pl_path_ids)

  # Group SKUs by product line (item belongs to PL if item's path descends from PL's path)
  skus_by_pl = pls.index_with { [] }
  items_data.each do |sku, item_path|
    next if sku.blank? || item_path.blank?

    item_path_str = item_path.to_s
    pls.each do |pl|
      pl_path = paths_by_id[pl.id]
      next unless pl_path.present?

      # Check if item is descendant of this PL (path starts with PL path)
      pl_path_str = pl_path.to_s
      skus_by_pl[pl] << sku if item_path_str == pl_path_str || item_path_str.start_with?("#{pl_path_str}.")
    end
  end

  # Now compute stats for each PL's SKUs in bulk
  all_skus = skus_by_pl.values.flatten.uniq
  return pls.index_with { { star_avg: nil, num: 0 } } if all_skus.empty?

  # Get all reviews for all SKUs at once
  reviews_by_sku = active.visible.product_reviews.by_skus(all_skus)
                         .group(:sku)
                         .pluck(:sku, Arel.sql('AVG(rating)'), Arel.sql('COUNT(*)'))
                         .index_by(&:first)

  # Compute stats per PL
  pls.each_with_object({}) do |pl, result|
    pl_skus = skus_by_pl[pl]
    if pl_skus.blank?
      result[pl.id] = { star_avg: nil, num: 0 }
      next
    end

    total_rating = 0.0
    total_count = 0
    pl_skus.each do |sku|
      review_data = reviews_by_sku[sku]
      next unless review_data

      avg = review_data[1]&.to_f || 0
      count = review_data[2].to_i
      total_rating += avg * count
      total_count += count
    end

    result[pl.id] = {
      star_avg: total_count.positive? ? (total_rating / total_count).round(2) : nil,
      num: total_count
    }
  end
end

.stats_for_skus(skus) ⇒ Hash

Get review stats for specific SKUs (aggregated)

Parameters:

  • skus (Array<String>)

    Array of SKUs

Returns:

  • (Hash)

    { star_avg: Float, num: Integer }



344
345
346
347
348
349
350
351
352
# File 'app/models/reviews_io.rb', line 344

def self.stats_for_skus(skus)
  return { star_avg: nil, num: 0 } if skus.blank?

  scope = active.visible.product_reviews.by_skus(skus)
  {
    star_avg: scope.average(:rating)&.round(2),
    num: scope.count
  }
end

.store_review_statsHash

Get store review stats (average rating and count)

Returns:

  • (Hash)

    { star_avg: Float, num: Integer }



200
201
202
203
204
205
206
# File 'app/models/reviews_io.rb', line 200

def self.store_review_stats
  scope = active.visible.store_reviews
  {
    star_avg: scope.average(:rating)&.round(2),
    num: scope.count
  }
end

.store_reviewsActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are store reviews. Active Record Scope

Returns:

See Also:



136
# File 'app/models/reviews_io.rb', line 136

scope :store_reviews, -> { where(review_type: 'store_review') }

.tags_includeActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are tags include. Active Record Scope

Returns:

See Also:



146
147
148
149
150
151
# File 'app/models/reviews_io.rb', line 146

scope :tags_include, ->(*tag_names) {
  tags = [tag_names].flatten.map(&:presence).compact
  return all if tags.empty?

  where("tags ?| ARRAY[#{tags.map { |t| connection.quote(t) }.join(', ')}]")
}

.upsert_from_api(review_data, import_time: Time.current) ⇒ ReviewsIo

Upsert a review from Reviews.io API response

Parameters:

  • review_data (Hash)

    Raw review data from API

  • import_time (Time) (defaults to: Time.current)

    The timestamp of this import run

Returns:

  • (ReviewsIo)

    The created or updated record



643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
# File 'app/models/reviews_io.rb', line 643

def upsert_from_api(review_data, import_time: Time.current)
  reviews_io_id = review_data['id']&.to_s
  return nil if reviews_io_id.blank?

  record = find_or_initialize_by(reviews_io_id: reviews_io_id)
  record.assign_attributes(
    review_type: review_data['type'],
    sku: review_data['sku'],
    rating: review_data['rating'],
    title: review_data['title'],
    comments: review_data['comments'],
    author_name: extract_author_name(review_data),
    author_location: extract_author_location(review_data),
    author_email: extract_author_email(review_data),
    review_date: parse_review_date(review_data['date_created']),
    order_id: review_data['order_id'],
    photos: review_data['photos'] || [],
    videos: review_data['videos'] || [],
    review_attributes: review_data['attributes'] || {},
    tags: merge_tags_with_crm(record.tags, review_data['tags'] || []),
    replies: review_data['replies'] || [],
    hidden: Api::ReviewsIo::ReviewPayload.hidden_after_sync?(review_data, record),
    status: STATUS_ACTIVE,
    last_seen_at: import_time,
    deleted_at: nil
  )
  record.save!
  record
end

.verifiedActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are verified. Active Record Scope

Returns:

See Also:



144
# File 'app/models/reviews_io.rb', line 144

scope :verified, -> { where(verified_buyer: true) }

.visibleActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are visible. Active Record Scope

Returns:

See Also:



131
# File 'app/models/reviews_io.rb', line 131

scope :visible,  -> { where(hidden: false) }

.with_commentsActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are with comments. Active Record Scope

Returns:

See Also:



143
# File 'app/models/reviews_io.rb', line 143

scope :with_comments, -> { where.not(comments: [nil, '']) }

.with_imported_imagesActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are with imported images. Active Record Scope

Returns:

See Also:



161
# File 'app/models/reviews_io.rb', line 161

scope :with_imported_images, ->(_value = true) { joins(:reviews_io_images).distinct }

.with_imported_videosActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are with imported videos. Active Record Scope

Returns:

See Also:



166
# File 'app/models/reviews_io.rb', line 166

scope :with_imported_videos, -> { joins(:reviews_io_videos).distinct }

.with_photosActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are with photos. Active Record Scope

Returns:

See Also:



158
159
160
# File 'app/models/reviews_io.rb', line 158

scope :with_photos, ->(_value = true) {
  where("jsonb_array_length(COALESCE(photos, '[]'::jsonb)) > 0 OR jsonb_array_length(COALESCE(raw_api_data->'photos_raw', '[]'::jsonb)) > 0")
}

.with_videosActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are with videos. Active Record Scope

Returns:

See Also:



163
164
165
# File 'app/models/reviews_io.rb', line 163

scope :with_videos, -> {
  where("jsonb_array_length(COALESCE(videos, '[]'::jsonb)) > 0 OR jsonb_array_length(COALESCE(raw_api_data->'videos', '[]'::jsonb)) > 0")
}

.without_imported_imagesActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are without imported images. Active Record Scope

Returns:

See Also:



162
# File 'app/models/reviews_io.rb', line 162

scope :without_imported_images, -> { where.not(id: ReviewsIoImage.select(:reviews_io_id)) }

.without_imported_videosActiveRecord::Relation<ReviewsIo>

A relation of ReviewsIos that are without imported videos. Active Record Scope

Returns:

See Also:



167
# File 'app/models/reviews_io.rb', line 167

scope :without_imported_videos, -> { where.not(id: ReviewsIoVideo.select(:reviews_io_id)) }

Instance Method Details

#active?Boolean

Instance methods

Returns:

  • (Boolean)


404
405
406
# File 'app/models/reviews_io.rb', line 404

def active?
  status == STATUS_ACTIVE
end

#all_photos_imported?Boolean

Callers iterating over multiple records should preload associations:
ReviewsIo.includes(:reviews_io_images, :reviews_io_videos)

Returns:

  • (Boolean)


568
569
570
571
572
# File 'app/models/reviews_io.rb', line 568

def all_photos_imported?
  return true unless has_photos?

  reviews_io_images.loaded? ? reviews_io_images.length >= photo_urls.size : reviews_io_images.size >= photo_urls.size
end

#all_videos_imported?Boolean

Returns:

  • (Boolean)


574
575
576
577
578
# File 'app/models/reviews_io.rb', line 574

def all_videos_imported?
  return true unless has_videos?

  reviews_io_videos.loaded? ? reviews_io_videos.length >= video_urls.size : reviews_io_videos.size >= video_urls.size
end

#avatar_imageImage

Returns:

See Also:



79
# File 'app/models/reviews_io.rb', line 79

belongs_to :avatar_image, class_name: 'Image', optional: true, foreign_key: :avatar_image_id

#content_for_embedding(_content_type = :primary) ⇒ Object

Generate content for semantic search embedding
Includes the review text plus product context



445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
# File 'app/models/reviews_io.rb', line 445

def content_for_embedding(_content_type = :primary)
  parts = []

  # Review content
  parts << "Review Title: #{title}" if title.present?
  parts << "Review: #{comments}" if comments.present?

  # Rating context
  parts << "Rating: #{rating} out of 5 stars" if rating.present?
  parts << 'Verified Buyer' if verified_buyer?

  # Product context from SKU
  if sku.present?
    item = Item.find_by(sku: sku)
    if item
      parts << "Product: #{item.name}"
      if item.primary_product_line
        parts << "Product Line: #{item.primary_product_line.lineage_expanded}"
      end
    end
  end

  # Location context (where the product was installed)
  parts << "Location: #{author_location}" if author_location.present?

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

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

#deleted?Boolean

Returns:

  • (Boolean)


408
409
410
# File 'app/models/reviews_io.rb', line 408

def deleted?
  status == STATUS_DELETED
end

#embedding_content_changed?Boolean

Detect if content changed (for automatic re-embedding)

Returns:

  • (Boolean)


477
478
479
# File 'app/models/reviews_io.rb', line 477

def embedding_content_changed?
  saved_change_to_title? || saved_change_to_comments? || saved_change_to_rating?
end

#full_reviewObject

Alias comments to full_review for view compatibility



492
493
494
# File 'app/models/reviews_io.rb', line 492

def full_review
  comments
end

#has_media?Boolean

Returns true if the review has any media (photos or videos).

Returns:

  • (Boolean)


562
563
564
# File 'app/models/reviews_io.rb', line 562

def has_media?
  has_photos? || has_videos?
end

#has_photos?Boolean

Returns true if the review has any photos — checks both the photos column
and photos_raw in raw_api_data (the API sometimes populates one but not the other).

Returns:

  • (Boolean)


530
531
532
533
# File 'app/models/reviews_io.rb', line 530

def has_photos?
  (photos.is_a?(Array) && photos.any?) ||
    raw_api_data_photos_raw.any?
end

#has_videos?Boolean

Returns true if the review has any video assets.

Returns:

  • (Boolean)


544
545
546
547
# File 'app/models/reviews_io.rb', line 544

def has_videos?
  (videos.is_a?(Array) && videos.any?) ||
    raw_api_data_videos.any?
end

#headline_reviewObject

Returns the headline used on review cards and in schema.org markup.
Staff-set custom_headline takes priority; falls back to the Reviews.io title.
The sync never writes custom_headline, so it is safe from being overridden.



487
488
489
# File 'app/models/reviews_io.rb', line 487

def headline_review
  custom_headline.presence || title
end

#imagesActiveRecord::Relation<Image>

Returns:

  • (ActiveRecord::Relation<Image>)

See Also:



76
# File 'app/models/reviews_io.rb', line 76

has_many :images, through: :reviews_io_images

#imported_videosActiveRecord::Relation<ImportedVideo>

Returns:

  • (ActiveRecord::Relation<ImportedVideo>)

See Also:



78
# File 'app/models/reviews_io.rb', line 78

has_many :imported_videos, through: :reviews_io_videos, source: :video

#mark_as_deleted!Object



580
581
582
# File 'app/models/reviews_io.rb', line 580

def mark_as_deleted!
  update!(status: STATUS_DELETED, deleted_at: Time.current)
end

#mark_as_seen!Object



584
585
586
# File 'app/models/reviews_io.rb', line 584

def mark_as_seen!
  update!(last_seen_at: Time.current, status: STATUS_ACTIVE, deleted_at: nil)
end

#obfuscated_reviewer_nameObject

Return obfuscated name for privacy (matches ReviewProxy behavior)



517
518
519
520
521
# File 'app/models/reviews_io.rb', line 517

def obfuscated_reviewer_name
  return nil if author_name.blank?

  PersonNameParser.new(author_name).obfuscated_name
end

#photo_urlsObject

Extracts photo URLs from photos column first, falling back to photos_raw in raw_api_data.
Returns the original S3 URLs whenever possible (photos_raw) rather than resized thumbnails.



537
538
539
540
541
# File 'app/models/reviews_io.rb', line 537

def photo_urls
  urls = extract_urls_from_photos
  urls = extract_urls_from_photos_raw if urls.empty?
  urls
end

#reviewer_cityObject

Extract city from author_location (format: "City, State" or "City, Country")



502
503
504
505
506
# File 'app/models/reviews_io.rb', line 502

def reviewer_city
  return nil if author_location.blank?

  author_location.split(',').first&.strip
end

#reviewer_nameObject

Accessor for reviewer_name (alias of author_name)



524
525
526
# File 'app/models/reviews_io.rb', line 524

def reviewer_name
  author_name
end

#reviewer_stateObject

Extract state from author_location



509
510
511
512
513
514
# File 'app/models/reviews_io.rb', line 509

def reviewer_state
  return nil if author_location.blank?

  parts = author_location.split(',')
  parts[1]&.strip if parts.size > 1
end

#reviews_io_imagesActiveRecord::Relation<ReviewsIoImage>

Associations

Returns:

See Also:



75
# File 'app/models/reviews_io.rb', line 75

has_many :reviews_io_images, dependent: :destroy

#reviews_io_videosActiveRecord::Relation<ReviewsIoVideo>

Returns:

See Also:



77
# File 'app/models/reviews_io.rb', line 77

has_many :reviews_io_videos, dependent: :destroy

#star_rating_levelObject

Alias rating to star_rating_level for view compatibility



497
498
499
# File 'app/models/reviews_io.rb', line 497

def star_rating_level
  rating
end

#video_urlsObject

Extracts video URLs from the videos column or raw_api_data.



550
551
552
553
554
555
556
557
558
559
# File 'app/models/reviews_io.rb', line 550

def video_urls
  vids = Array(videos).filter_map do |v|
    v.is_a?(Hash) ? (v['video'] || v['url']) : v.presence
  end
  return vids if vids.any?

  raw_api_data_videos.filter_map do |v|
    v.is_a?(Hash) ? (v['video'] || v['url']) : v.presence
  end
end