""" Daily reminder scheduler. Runs every minute. For each user with an enabled reminder: - Converts current UTC time to the user's local timezone - Checks if the current HH:MM matches their reminder time - Checks if they already got a notification today (avoids duplicates) - Checks if they have already written a journal entry today - If not, sends an FCM push notification to all their registered devices """ import json import logging from datetime import datetime, timedelta import pytz import firebase_admin from firebase_admin import credentials, messaging from apscheduler.schedulers.background import BackgroundScheduler from config import get_settings from db import get_database log = logging.getLogger(__name__) _firebase_initialized = False def init_firebase(): """Initialize Firebase Admin SDK once using the service account JSON from env.""" global _firebase_initialized if _firebase_initialized: return settings = get_settings() if not settings.firebase_service_account_json: log.warning("FIREBASE_SERVICE_ACCOUNT_JSON not set — push notifications disabled") return try: sa_dict = json.loads(settings.firebase_service_account_json) cred = credentials.Certificate(sa_dict) firebase_admin.initialize_app(cred) _firebase_initialized = True log.info("Firebase Admin SDK initialized") except Exception as e: log.error(f"Failed to initialize Firebase Admin SDK: {e}") def send_reminder_notifications(): """Check all users and send reminders where due.""" if not _firebase_initialized: return db = get_database() now_utc = datetime.utcnow().replace(second=0, microsecond=0) # Find all users with reminder enabled and at least one FCM token users = db.users.find({ "reminder.enabled": True, "fcmTokens": {"$exists": True, "$not": {"$size": 0}}, "reminder.time": {"$exists": True}, }) for user in users: try: _process_user(db, user, now_utc) except Exception as e: log.error(f"Error processing reminder for user {user.get('_id')}: {e}") def _process_user(db, user: dict, now_utc: datetime): reminder = user.get("reminder", {}) reminder_time_str = reminder.get("time") # "HH:MM" timezone_str = reminder.get("timezone", "UTC") fcm_tokens: list = user.get("fcmTokens", []) if not reminder_time_str or not fcm_tokens: return try: user_tz = pytz.timezone(timezone_str) except pytz.UnknownTimeZoneError: user_tz = pytz.utc # Current time in user's timezone now_local = now_utc.replace(tzinfo=pytz.utc).astimezone(user_tz) current_hm = now_local.strftime("%H:%M") if current_hm != reminder_time_str: return # Not the right minute # Check if already notified today (in user's local date) today_local_str = now_local.strftime("%Y-%m-%d") last_notified = reminder.get("lastNotifiedDate", "") if last_notified == today_local_str: return # Already sent today # Check if user has already written today (using createdAt in their timezone) today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0) today_start_utc = today_start_local.astimezone(pytz.utc).replace(tzinfo=None) today_end_utc = today_start_utc + timedelta(days=1) entry_count = db.entries.count_documents({ "userId": user["_id"], "createdAt": {"$gte": today_start_utc, "$lt": today_end_utc}, }) if entry_count > 0: # Already wrote today — mark notified to avoid repeated checks db.users.update_one( {"_id": user["_id"]}, {"$set": {"reminder.lastNotifiedDate": today_local_str}} ) return # Send FCM notification _send_push(user["_id"], fcm_tokens, db, today_local_str) def _send_push(user_id, tokens: list, db, today_local_str: str): """Send FCM multicast and prune stale tokens.""" message = messaging.MulticastMessage( notification=messaging.Notification( title="Time to journal 🌱", body="You haven't written today yet. Take a moment to reflect.", ), tokens=tokens, android=messaging.AndroidConfig(priority="high"), apns=messaging.APNSConfig( payload=messaging.APNSPayload( aps=messaging.Aps(sound="default") ) ), webpush=messaging.WebpushConfig( notification=messaging.WebpushNotification( icon="/web-app-manifest-192x192.png", badge="/favicon-96x96.png", tag="gj-daily-reminder", ) ), ) response = messaging.send_each_for_multicast(message) log.info(f"Reminder sent to user {user_id}: {response.success_count} ok, {response.failure_count} failed") # Remove tokens that are no longer valid stale_tokens = [ tokens[i] for i, r in enumerate(response.responses) if not r.success and r.exception and "not-registered" in str(r.exception).lower() ] if stale_tokens: db.users.update_one( {"_id": user_id}, {"$pullAll": {"fcmTokens": stale_tokens}} ) log.info(f"Removed {len(stale_tokens)} stale FCM tokens for user {user_id}") # Mark today as notified db.users.update_one( {"_id": user_id}, {"$set": {"reminder.lastNotifiedDate": today_local_str}} ) def start_scheduler() -> BackgroundScheduler: """Initialize Firebase and start the minute-by-minute scheduler.""" init_firebase() scheduler = BackgroundScheduler(timezone="UTC") scheduler.add_job( send_reminder_notifications, trigger="cron", minute="*", # every minute id="daily_reminders", replace_existing=True, ) scheduler.start() log.info("Reminder scheduler started") return scheduler