From 0739a688ddc56685e440b042377f16fa90144a84 Mon Sep 17 00:00:00 2001
From: Peter Woolery
Date: Sat, 9 May 2026 13:40:05 -0700
Subject: [PATCH] feat: enrich proposal email with ingredients, cost, shopping
preview
Co-Authored-By: Claude Sonnet 4.6 (1M context)
---
backend/app/services/orchestrator/steps.py | 112 ++++++++++++++++++++-
1 file changed, 107 insertions(+), 5 deletions(-)
diff --git a/backend/app/services/orchestrator/steps.py b/backend/app/services/orchestrator/steps.py
index 75728a9..9625779 100644
--- a/backend/app/services/orchestrator/steps.py
+++ b/backend/app/services/orchestrator/steps.py
@@ -16,7 +16,7 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING
from app.config import settings
-from app.models import FamilyMember, FamilyProfile, MealPlan, MealPlanItemStatus, MealPlanVote
+from app.models import FamilyMember, FamilyProfile, Ingredient, IngredientGroceryMatch, MealPlan, MealPlanItemStatus, MealPlanVote
from app.services.approval import issue_token
from app.services.email import get_email_backend
from app.services.orchestrator.alerts import send_admin_alert
@@ -122,6 +122,57 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
"days old — the Friday scrape failed and stale data was used.
"
)
+ # Build shopping list preview once (shared across all member emails)
+ all_ingredients: dict[str, tuple[str, str, str, str]] = {}
+ for item in plan.items:
+ if item.approval_status != MealPlanItemStatus.PENDING:
+ continue
+ for ing in (item.recipe.ingredients or [] if item.recipe else []):
+ ing_name = ing.get("name", "").strip()
+ if not ing_name or ing_name in all_ingredients:
+ continue
+ match = (
+ db.query(IngredientGroceryMatch)
+ .join(Ingredient, IngredientGroceryMatch.ingredient_id == Ingredient.id)
+ .filter(Ingredient.name_lower == ing_name.lower())
+ .order_by(IngredientGroceryMatch.confidence.desc())
+ .first()
+ )
+ grocery_name = match.grocery_item.name if match and match.grocery_item else "—"
+ price = (
+ f"${float(match.grocery_item.current_price):.2f}"
+ if match and match.grocery_item and match.grocery_item.current_price
+ else "—"
+ )
+ all_ingredients[ing_name] = (
+ ing.get("qty", ""),
+ ing.get("unit", ""),
+ grocery_name,
+ price,
+ )
+
+ if all_ingredients:
+ shop_rows = "".join(
+ f"| {html.escape(name)} | "
+ f"{html.escape(qty)} {html.escape(unit)} | "
+ f"{html.escape(grocery)} | "
+ f"{html.escape(price)} |
"
+ for name, (qty, unit, grocery, price) in all_ingredients.items()
+ )
+ shopping_preview = (
+ f"
"
+ f"Estimated shopping list
"
+ f""
+ f""
+ f"| Ingredient | "
+ f"Qty | "
+ f"At Lucky | "
+ f"Price | "
+ f"
{shop_rows}
"
+ )
+ else:
+ shopping_preview = ""
+
backend = get_email_backend()
for member in members:
item_html_parts = []
@@ -135,19 +186,70 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
recipe_name = html.escape(
item.recipe.name if item.recipe else str(item.recipe_id)
)
+
+ # Image block
+ img_block = (
+ f'
'
+ if item.recipe and item.recipe.image_url else ""
+ )
+
+ # Ingredients list
+ ingredients = item.recipe.ingredients or [] if item.recipe else []
+ ing_rows = "".join(
+ f"{html.escape(str(ing.get('qty', '')))}"
+ f" {html.escape(str(ing.get('unit', '')))}"
+ f" {html.escape(str(ing.get('name', '')))}"
+ for ing in ingredients
+ )
+ ing_block = (
+ f""
+ if ing_rows else ""
+ )
+
+ # Estimated cost: sum top-confidence grocery match prices
+ est_cost = 0.0
+ for ing in ingredients:
+ ing_name = ing.get("name", "").lower()
+ match = (
+ db.query(IngredientGroceryMatch)
+ .join(Ingredient, IngredientGroceryMatch.ingredient_id == Ingredient.id)
+ .filter(Ingredient.name_lower == ing_name)
+ .order_by(IngredientGroceryMatch.confidence.desc())
+ .first()
+ )
+ if match and match.grocery_item and match.grocery_item.current_price:
+ est_cost += float(match.grocery_item.current_price)
+
+ cost_block = (
+ f"Est. cost: ~${est_cost:.2f}
"
+ if est_cost > 0 else ""
+ )
+
item_html_parts.append(
- f'{recipe_name} — Vote'
+ f''
)
if not item_html_parts:
continue
email_html = (
+ f""
f"
This week's meal suggestions
"
f"{stale_banner}"
- f"
Hi {html.escape(member.name)}, please vote on this week's meals by Fri 17:00 PT:
"
- f"
{''.join(item_html_parts)}
"
- f"
Silence = approved. Any denial removes that meal.
"
+ f"
Hi {html.escape(member.name)}, please vote on this week's meals by Fri 17:00 PT. "
+ f"Silence = approved. Any denial removes that meal.
"
+ f"{''.join(item_html_parts)}"
+ f"{shopping_preview}"
+ f"
"
)
backend.send(
to=member.email,