import logging import os from datetime import datetime, timezone from apscheduler.schedulers.background import BackgroundScheduler from tinydb import Query logger = logging.getLogger(__name__) def _get_local_hour(tz_str: str | None) -> int: """Return the current hour (0-23) in the given IANA timezone, falling back to UTC.""" try: if tz_str: from zoneinfo import ZoneInfo # Python 3.9+ local_now = datetime.now(ZoneInfo(tz_str)) return local_now.hour except Exception: pass return datetime.now(timezone.utc).hour def send_digests(app) -> None: """Hourly job: send the 9pm digest to every eligible user whose local time is 21.""" if os.environ.get('DB_ENV') == 'e2e': return with app.app_context(): try: from db.db import users_db, pending_confirmations_db, child_db, task_db, reward_db from utils.email_sender import send_digest_email from utils.digest_token import create_action_token, create_unsubscribe_token from flask import current_app frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173') UserQ = Query() users = users_db.search( (UserQ.verified == True) & (UserQ.email_digest_enabled == True) ) for user_dict in users: user_id = user_dict.get('id') user_email = user_dict.get('email') user_tz = user_dict.get('timezone') local_hour = _get_local_hour(user_tz) if local_hour != 21: continue # Gather pending items for this user PendingQ = Query() pending_items = pending_confirmations_db.search( (PendingQ.user_id == user_id) & (PendingQ.status == 'pending') ) if not pending_items: continue ChildQ = Query() TaskQ = Query() RewardQ = Query() items = [] for p in pending_items: child_id = p.get('child_id') entity_id = p.get('entity_id') entity_type = p.get('entity_type') child_result = child_db.get(ChildQ.id == child_id) if not child_result: logger.warning(f'Digest: child {child_id} not found for pending item {p.get("id")} (user {user_id})') continue child_name = child_result.get('name') if not child_name: logger.warning(f'Digest: child {child_id} has no name (user {user_id})') child_name = 'Unknown' if entity_type == 'chore': entity_result = task_db.get( (TaskQ.id == entity_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None)) ) else: entity_result = reward_db.get( (RewardQ.id == entity_id) & ((RewardQ.user_id == user_id) | (RewardQ.user_id == None)) ) if not entity_result: logger.warning(f'Digest: {entity_type} {entity_id} not found for pending item {p.get("id")} (user {user_id})') continue entity_name = entity_result.get('name') if not entity_name: logger.warning(f'Digest: {entity_type} {entity_id} has no name (user {user_id})') entity_name = 'Unknown' approve_token = create_action_token( user_id=user_id, child_id=child_id, entity_id=entity_id, entity_type=entity_type, action='approve', ) deny_token = create_action_token( user_id=user_id, child_id=child_id, entity_id=entity_id, entity_type=entity_type, action='deny', ) view_url = ( f"{frontend_url}/parent/{child_id}" f"?scrollTo={entity_id}&entityType={entity_type}" ) approve_url = f"{frontend_url}/api/digest-action/{approve_token.id}" deny_url = f"{frontend_url}/api/digest-action/{deny_token.id}" items.append({ 'child_name': child_name, 'entity_name': entity_name, 'entity_type': entity_type, 'view_url': view_url, 'approve_url': approve_url, 'deny_url': deny_url, 'child_id': child_id, 'entity_id': entity_id, }) if not items: continue unsubscribe_token = create_unsubscribe_token(user_id) try: send_digest_email(user_email, items, unsubscribe_token) except Exception as e: logger.error(f'Failed to send digest to {user_email}: {e}') except Exception as e: logger.error(f'Error in send_digests job: {e}') def start_digest_scheduler(app) -> BackgroundScheduler: """Start the hourly digest scheduler. Returns the scheduler instance.""" scheduler = BackgroundScheduler() scheduler.add_job( send_digests, 'cron', minute=0, args=[app], id='digest_scheduler', replace_existing=True, ) scheduler.start() logger.info('Digest scheduler started (hourly)') return scheduler