diff --git a/backend/app/services/orchestrator/steps.py b/backend/app/services/orchestrator/steps.py
index 1bb55e4..11d61b7 100644
--- a/backend/app/services/orchestrator/steps.py
+++ b/backend/app/services/orchestrator/steps.py
@@ -10,12 +10,13 @@ All service imports are at module level so tests can monkeypatch via
"""
from __future__ import annotations
+import html
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from app.config import settings
-from app.models import FamilyMember, FamilyProfile, MealPlan, MealPlanItemStatus
+from app.models import FamilyMember, FamilyProfile, 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
@@ -255,3 +256,95 @@ def step_finalize(run: "WeeklyRun", db: "Session") -> None:
run.status = "completed"
db.commit()
logger.info("step_finalize: done, %d approved meals", len(approved_items))
+
+
+def step_reminder(run: "WeeklyRun", db: "Session") -> None:
+ if run.reminded_at is not None:
+ logger.info("step_reminder: already done for %s", run.week_start_date)
+ return
+
+ if run.emailed_at is None:
+ logger.info("step_reminder: proposal not sent yet for %s, skipping", run.week_start_date)
+ return
+
+ plan = (
+ db.query(MealPlan)
+ .filter(
+ MealPlan.family_profile_id == run.family_id,
+ MealPlan.week_start_date == run.week_start_date,
+ )
+ .first()
+ )
+ if plan is None:
+ run.reminded_at = datetime.now(timezone.utc)
+ db.commit()
+ return
+
+ pending_item_ids = [
+ item.id
+ for item in plan.items
+ if item.approval_status == MealPlanItemStatus.PENDING
+ ]
+ if not pending_item_ids:
+ run.reminded_at = datetime.now(timezone.utc)
+ db.commit()
+ return
+
+ members = (
+ db.query(FamilyMember)
+ .filter(
+ FamilyMember.family_profile_id == run.family_id,
+ FamilyMember.email.isnot(None),
+ )
+ .all()
+ )
+
+ backend = get_email_backend()
+ for member in members:
+ voted_ids = {
+ v.meal_plan_item_id
+ for v in db.query(MealPlanVote)
+ .filter(
+ MealPlanVote.meal_plan_item_id.in_(pending_item_ids),
+ MealPlanVote.family_member_id == member.id,
+ )
+ .all()
+ }
+ unvoted = [
+ item
+ for item in plan.items
+ if item.approval_status == MealPlanItemStatus.PENDING
+ and item.id not in voted_ids
+ ]
+ if not unvoted:
+ continue
+
+ item_html_parts = []
+ for item in unvoted:
+ token = issue_token(item.id, member.id)
+ vote_url = (
+ f"{settings.APP_BASE_URL}/api/meals/vote/{item.id}?token={token}"
+ )
+ recipe_name = html.escape(
+ item.recipe.name if item.recipe else str(item.recipe_id)
+ )
+ item_html_parts.append(
+ f'
{recipe_name} — Vote'
+ )
+
+ email_html = (
+ f"Vote closes in 1 hour!
"
+ f"Hi {html.escape(member.name)}, the meal plan vote closes at Fri 17:00 PT. "
+ f"You haven't voted on:
"
+ f"{''.join(item_html_parts)}
"
+ )
+ backend.send(
+ to=member.email,
+ subject=f"Meal plan vote closes in 1 hour — week of {run.week_start_date}",
+ html=email_html,
+ )
+ logger.info("step_reminder: sent to %s", member.email)
+
+ run.reminded_at = datetime.now(timezone.utc)
+ db.commit()
+ logger.info("step_reminder: done for %s", run.week_start_date)
diff --git a/backend/tests/test_orchestrator.py b/backend/tests/test_orchestrator.py
index 9689765..c09960a 100644
--- a/backend/tests/test_orchestrator.py
+++ b/backend/tests/test_orchestrator.py
@@ -135,6 +135,23 @@ def weekly_run_emailed(db, family):
return r
+@pytest.fixture()
+def weekly_run_reminded(db, family):
+ from app.models import WeeklyRun
+ r = WeeklyRun(
+ family_id=family.id,
+ week_start_date=WEEK,
+ status="running",
+ scraped_at=datetime.now(timezone.utc),
+ generated_at=datetime.now(timezone.utc),
+ emailed_at=datetime.now(timezone.utc),
+ reminded_at=datetime.now(timezone.utc),
+ )
+ db.add(r)
+ db.flush()
+ return r
+
+
# ---------------------------------------------------------------------------
# alerts.py tests
# ---------------------------------------------------------------------------
@@ -299,6 +316,84 @@ def test_step_email_stale_banner(db, weekly_run_generated, meal_plan, pending_it
assert "prices" in sent[0]["html"].lower() or "stale" in sent[0]["html"].lower()
+# ── step_reminder ──────────────────────────────────────────────────────────
+
+
+def test_step_reminder_idempotent(db, weekly_run_reminded):
+ original_ts = weekly_run_reminded.reminded_at
+ from app.services.orchestrator.steps import step_reminder
+ step_reminder(weekly_run_reminded, db)
+ assert weekly_run_reminded.reminded_at == original_ts
+
+
+def test_step_reminder_skips_when_not_emailed(db, weekly_run_generated, monkeypatch):
+ sent = []
+
+ class FakeBackend:
+ def send(self, **kwargs):
+ sent.append(kwargs)
+
+ monkeypatch.setattr(
+ "app.services.orchestrator.steps.get_email_backend",
+ lambda: FakeBackend(),
+ )
+ from app.services.orchestrator.steps import step_reminder
+ step_reminder(weekly_run_generated, db)
+ # emailed_at is None on weekly_run_generated — should be a no-op
+ assert len(sent) == 0
+ assert weekly_run_generated.reminded_at is None
+
+
+def test_step_reminder_sends_to_non_voter(
+ db, weekly_run_emailed, meal_plan, pending_item, member, monkeypatch
+):
+ sent = []
+
+ class FakeBackend:
+ def send(self, **kwargs):
+ sent.append(kwargs)
+
+ monkeypatch.setattr(
+ "app.services.orchestrator.steps.get_email_backend",
+ lambda: FakeBackend(),
+ )
+ from app.services.orchestrator.steps import step_reminder
+ step_reminder(weekly_run_emailed, db)
+ assert weekly_run_emailed.reminded_at is not None
+ assert len(sent) == 1
+ assert sent[0]["to"] == "alice@example.com"
+ assert "1 hour" in sent[0]["subject"].lower() or "closes" in sent[0]["subject"].lower()
+
+
+def test_step_reminder_skips_voter(
+ db, weekly_run_emailed, meal_plan, pending_item, member, monkeypatch
+):
+ from app.models import MealPlanVote
+ vote = MealPlanVote(
+ meal_plan_item_id=pending_item.id,
+ family_member_id=member.id,
+ vote=True,
+ )
+ db.add(vote)
+ db.flush()
+
+ sent = []
+
+ class FakeBackend:
+ def send(self, **kwargs):
+ sent.append(kwargs)
+
+ monkeypatch.setattr(
+ "app.services.orchestrator.steps.get_email_backend",
+ lambda: FakeBackend(),
+ )
+ from app.services.orchestrator.steps import step_reminder
+ step_reminder(weekly_run_emailed, db)
+ # member already voted — no reminder
+ assert len(sent) == 0
+ assert weekly_run_emailed.reminded_at is not None
+
+
# ---------------------------------------------------------------------------
# step_deadline tests
# ---------------------------------------------------------------------------