import logging import os from datetime import datetime, timezone from apscheduler.schedulers.background import BackgroundScheduler from tinydb import Query from events.types.child_chore_confirmation import ChildChoreConfirmation from events.types.child_reward_request import ChildRewardRequest from events.types.event import Event from events.types.event_types import EventType from events.sse import send_event_to_user logger = logging.getLogger(__name__) def _get_today_start_timestamp(tz_str: str | None) -> float: """Return a Unix timestamp for the start of today (midnight) in the user's timezone.""" try: if tz_str: from zoneinfo import ZoneInfo now_local = datetime.now(ZoneInfo(tz_str)) today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0) return today_local.timestamp() except Exception: pass now_utc = datetime.now(timezone.utc) today_utc = now_utc.replace(hour=0, minute=0, second=0, microsecond=0) return today_utc.timestamp() def expire_stale_pending_for_user(user_id: str, tz_str: str | None) -> int: """Delete stale pending confirmations for a user and fire SSE events. A record is stale if status='pending' and created_at is before the start of today in the user's timezone. Returns the number of records expired. Must be called within a Flask app context. """ from db.db import pending_confirmations_db today_start = _get_today_start_timestamp(tz_str) PendingQ = Query() stale_items = pending_confirmations_db.search( (PendingQ.user_id == user_id) & (PendingQ.status == 'pending') & (PendingQ.created_at < today_start) ) if not stale_items: return 0 for item in stale_items: item_id = item.get('id') child_id = item.get('child_id') entity_id = item.get('entity_id') entity_type = item.get('entity_type') pending_confirmations_db.remove(PendingQ.id == item_id) if entity_type == 'chore': event = Event( EventType.CHILD_CHORE_CONFIRMATION.value, ChildChoreConfirmation( child_id=child_id, task_id=entity_id, operation=ChildChoreConfirmation.OPERATION_RESET, ) ) else: event = Event( EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest( child_id=child_id, reward_id=entity_id, operation=ChildRewardRequest.REQUEST_CANCELLED, ) ) send_event_to_user(user_id, event) logger.info( f'State expiry: expired {entity_type} {entity_id} for child {child_id} (user {user_id})' ) return len(stale_items) def run_state_expiry_check(app) -> None: """Check all verified users and expire stale pending records.""" if os.environ.get('DB_ENV') == 'e2e': return with app.app_context(): try: from db.db import users_db UserQ = Query() users = users_db.search(UserQ.verified == True) for user_dict in users: user_id = user_dict.get('id') tz_str = user_dict.get('timezone') try: expired = expire_stale_pending_for_user(user_id, tz_str) if expired: logger.info(f'State expiry: expired {expired} record(s) for user {user_id}') except Exception as e: logger.error(f'State expiry: failed for user {user_id}: {e}') except Exception as e: logger.error(f'Error in run_state_expiry_check: {e}') def start_state_expiry_scheduler(app) -> BackgroundScheduler: """Start the hourly state expiry scheduler. Returns the scheduler instance.""" scheduler = BackgroundScheduler() scheduler.add_job( run_state_expiry_check, 'cron', minute=0, args=[app], id='state_expiry_scheduler', replace_existing=True, ) scheduler.start() logger.info('State expiry scheduler started (hourly)') # Run immediately on startup to handle any records that expired while server was down run_state_expiry_check(app) return scheduler