feat: step_reminder — 1-hour pre-deadline nudge for non-voters

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 18:00:16 -07:00
co-authored by Claude Sonnet 4.6
parent 5a41402644
commit d002485c10
2 changed files with 189 additions and 1 deletions
+94 -1
View File
@@ -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'<li>{recipe_name} — <a href="{vote_url}">Vote</a></li>'
)
email_html = (
f"<h2>Vote closes in 1 hour!</h2>"
f"<p>Hi {html.escape(member.name)}, the meal plan vote closes at Fri 17:00 PT. "
f"You haven't voted on:</p>"
f"<ul>{''.join(item_html_parts)}</ul>"
)
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)