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
Section titled “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
Section titled “Architecture”Database Schema
Section titled “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
Section titled “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
Section titled “Models”MaterialAlert
Section titled “MaterialAlert”# Primary model storing alert metadataclass 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 endendMaterialAlertItem
Section titled “MaterialAlertItem”# Join table linking alerts to recommended itemsclass MaterialAlertItem < ApplicationRecord belongs_to :material_alert belongs_to :item
default_scope { order(:position) }endFetching Alerts
Section titled “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
Section titled “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
Section titled “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
Section titled “Database Constraints”CHECK Constraint
Section titled “CHECK Constraint”Ensures exactly one resource FK is set:
ALTER TABLE material_alerts ADD CONSTRAINT chk_material_alerts_single_resourceCHECK ( (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
Section titled “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
Section titled “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
Section titled “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
Section titled “Performance Optimizations”Cart View (WWW)
Section titled “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
Section titled “CRM View”CRM uses separate partials (_crm_material_alert.html.erb) that don’t need the same optimization since they display differently.
Migration
Section titled “Migration”bundle exec rails db:migrateThe 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
Section titled “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