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.

Background

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.

Architecture

Database Schema

┌─────────────────────┐      ┌─────────────────────┐      ┌─────────────┐
│ 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) │
                         └────────────────────────────┘

Key Design Decisions

  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.

Models

MaterialAlert

# 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

MaterialAlertItem

# Join table linking alerts to recommended items
class MaterialAlertItem < ApplicationRecord
  belongs_to :material_alert
  belongs_to :item
  
  default_scope { order(:position) }
end

Usage

Fetching Alerts

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

Manual Invalidation

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.

Invalidation Triggers

Scenario Handling
Line items added/removed Automatic (signature changes)
Order split Explicit call to invalidate_material_alerts! in Order::Splitter
Quote to order conversion No action needed - new order generates on first access
Parent record deleted CASCADE DELETE removes alerts automatically
Item deleted CASCADE DELETE removes join record; alert regenerates on next access

Database Constraints

CHECK Constraint

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
)

Indexes

  • (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

Alert Attributes

Attribute Type Description
signature string MD5 hash of line items for invalidation
name string Human-readable alert message
recommended_qty integer How many items we recommend
actual_qty integer How many customer currently has
group_name string Grouping label (e.g., "Installation Kits")
group_type string CSS class for styling
unmaskable boolean If true, alert cannot be filtered by stock/customer rules

Unmaskable Alerts

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

Performance Optimizations

Cart View (WWW)

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 View

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

Migration

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

Related Files

  • 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