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
-
Explicit FKs (not polymorphic): Uses
order_id,quote_id,room_configuration_idcolumns 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_typefiltering
-
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. -
Eager loading: Alerts include items via
has_many :throughwith 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:
- Computes the current
line_items.signature - Checks for existing alerts matching that signature
- If found, returns them with items eager-loaded
- If not found, generates fresh alerts via
Item::Materials::Checkand 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_accessiblefor filteringproduct_stock_statusfor stock checkscatalog_itemfor 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:
- Creates
material_alertstable with explicit FK columns - Creates
material_alert_itemsjoin table - Adds composite indexes for FK + signature lookups
- Adds CHECK constraint for single-resource enforcement
- Adds foreign keys with CASCADE DELETE
Related Files
app/models/material_alert.rb- Main modelapp/models/material_alert_item.rb- Join modelapp/concerns/models/pickable.rb-get_material_alertsinterfaceapp/services/item/materials/check.rb- Alert generation logicapp/views/shared/_cart_material_alerts.html.erb- WWW cart renderingapp/views/shared/_crm_material_alert.html.erb- CRM renderingdb/migrate/20260121210000_create_material_alerts.rb- Migration