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"" + f"" + f"" + f"" + f"{shop_rows}
IngredientQtyAt LuckyPrice
" + ) + 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'
    ' + f'{img_block}' + f'

    {recipe_name}

    ' + f'{ing_block}' + f'{cost_block}' + f'' + f'Vote on this meal' + 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"" - 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,