Skip to content

Material Alerts Persistence

Material alerts are product recommendations shown to customers during checkout (e.g., “You need a thermostat for your floor heating system”). This feature persists these alerts in the database instead of using Rails.cache.

Problem: Rails.cache uses Marshal.dump for serialization, but the Virtus-based Item::Materials::Alert class creates anonymous class mixins that cannot be serialized. This caused TypeError: can't dump anonymous class errors (AppSignal incident #2919).

Solution: Persist material alerts in PostgreSQL using ActiveRecord models with explicit foreign keys.

┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────┐
│ Order/Quote/Room │──┬──▶│ material_alerts │──┬──▶│ material_ │
│ Configuration │ │ │ │ │ │ alert_items │
└─────────────────────┘ │ │ - signature │ │ │ │──▶ items
│ │ - name │ │ │ - position │
│ │ - recommended_qty │ │ └─────────────┘
│ │ - actual_qty │ │
│ │ - group_name │ │
│ │ - group_type │ │
│ │ - unmaskable │ │
│ └─────────────────────┘ │
│ │
│ (FK with CASCADE DELETE) │
└────────────────────────────┘
  1. Explicit FKs (not polymorphic): Uses order_id, quote_id, room_configuration_id columns with exactly one set at a time. This enables:

    • Proper CASCADE DELETE when parent is deleted
    • Database-enforced referential integrity via CHECK constraint
    • Cleaner queries without resource_type filtering
  2. Signature-based invalidation: Each alert stores a signature (MD5 hash of SKU:quantity pairs). When line items change, the signature changes, triggering regeneration on next access.

  3. Eager loading: Alerts include items via has_many :through with strategic preloading to avoid N+1 queries.

# Primary model storing alert metadata
class MaterialAlert < ApplicationRecord
belongs_to :order, optional: true
belongs_to :quote, optional: true
belongs_to :room_configuration, optional: true
has_many :material_alert_items, dependent: :destroy
has_many :items, through: :material_alert_items
# Backward compatibility with Virtus interface
def room
room_configuration
end
end
# Join table linking alerts to recommended items
class MaterialAlertItem < ApplicationRecord
belongs_to :material_alert
belongs_to :item
default_scope { order(:position) }
end

The primary interface is Pickable#get_material_alerts:

# On Order, Quote, or RoomConfiguration
@cart.get_material_alerts(for_www: true, for_cart: true)

This method:

  1. Computes the current line_items.signature
  2. Checks for existing alerts matching that signature
  3. If found, returns them with items eager-loaded
  4. If not found, generates fresh alerts via Item::Materials::Check and persists them

For edge cases where line items change outside normal flow:

@order.invalidate_material_alerts!

This deletes all alerts for the resource. They regenerate on next access.

ScenarioHandling
Line items added/removedAutomatic (signature changes)
Order splitExplicit call to invalidate_material_alerts! in Order::Splitter
Quote to order conversionNo action needed - new order generates on first access
Parent record deletedCASCADE DELETE removes alerts automatically
Item deletedCASCADE DELETE removes join record; alert regenerates on next access

Ensures exactly one resource FK is set:

ALTER TABLE material_alerts ADD CONSTRAINT chk_material_alerts_single_resource
CHECK (
(CASE WHEN order_id IS NOT NULL THEN 1 ELSE 0 END +
CASE WHEN quote_id IS NOT NULL THEN 1 ELSE 0 END +
CASE WHEN room_configuration_id IS NOT NULL THEN 1 ELSE 0 END) = 1
)
  • (order_id, signature) - Fast lookup for order alerts
  • (quote_id, signature) - Fast lookup for quote alerts
  • (room_configuration_id, signature) - Fast lookup for room alerts
  • (material_alert_id, item_id) - Unique constraint on join table
AttributeTypeDescription
signaturestringMD5 hash of line items for invalidation
namestringHuman-readable alert message
recommended_qtyintegerHow many items we recommend
actual_qtyintegerHow many customer currently has
group_namestringGrouping label (e.g., “Installation Kits”)
group_typestringCSS class for styling
unmaskablebooleanIf true, alert cannot be filtered by stock/customer rules

When unmaskable: true, the alert bypasses stock availability and customer eligibility filters. Used for:

  • Safety/compliance warnings - Watts/sqft exceeding state electrical codes
  • Critical system requirements - Controls required per heating element
  • Incompatibility errors - Items not recommended with certain controls
  • Required accessories - Junction boxes, power connection kits

The cart view batch-preloads ViewProductCatalog data to avoid N+1 queries when rendering material alert items:

<% vpc_by_item_id = ViewProductCatalog.where(item_id: all_item_ids, catalog_id: catalog_id)
.includes(:item, :catalog_item)
.index_by(&:item_id) %>

This provides:

  • item_is_web_accessible for filtering
  • product_stock_status for stock checks
  • catalog_item for pricing

CRM uses separate partials (_crm_material_alert.html.erb) that don’t need the same optimization since they display differently.

Terminal window
bundle exec rails db:migrate

The migration:

  1. Creates material_alerts table with explicit FK columns
  2. Creates material_alert_items join table
  3. Adds composite indexes for FK + signature lookups
  4. Adds CHECK constraint for single-resource enforcement
  5. Adds foreign keys with CASCADE DELETE
  • app/models/material_alert.rb - Main model
  • app/models/material_alert_item.rb - Join model
  • app/concerns/models/pickable.rb - get_material_alerts interface
  • app/services/item/materials/check.rb - Alert generation logic
  • app/views/shared/_cart_material_alerts.html.erb - WWW cart rendering
  • app/views/shared/_crm_material_alert.html.erb - CRM rendering
  • db/migrate/20260121210000_create_material_alerts.rb - Migration