From d002485c1053fd6afae587963192d3ed568b5393 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Fri, 8 May 2026 18:00:16 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20step=5Freminder=20=E2=80=94=201-hour=20?= =?UTF-8?q?pre-deadline=20nudge=20for=20non-voters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 (1M context) --- backend/app/services/orchestrator/steps.py | 95 +++++++++++++++++++++- backend/tests/test_orchestrator.py | 95 ++++++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) 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"" + ) + 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 # ---------------------------------------------------------------------------