""" 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: log.warning("Reminder check skipped — Firebase not initialized") return db = get_database() now_utc = datetime.utcnow().replace(second=0, microsecond=0) candidates = list(db.users.find({ "reminder.enabled": True, "fcmTokens": {"$exists": True, "$not": {"$size": 0}}, })) log.debug(f"Reminder check at {now_utc.strftime('%H:%M')} UTC — {len(candidates)} candidate(s)") for user in candidates: try: if user.get("reminder", {}).get("time"): _process_user(db, user, now_utc) _process_universal(db, user, now_utc) except Exception as e: log.error(f"Error processing reminder for user {user.get('_id')}: {e}") def _get_user_local_time(now_utc: datetime, timezone_str: str): """Returns (now_local, today_str, user_tz).""" try: user_tz = pytz.timezone(timezone_str) except pytz.UnknownTimeZoneError: user_tz = pytz.utc now_local = now_utc.replace(tzinfo=pytz.utc).astimezone(user_tz) today_str = now_local.strftime("%Y-%m-%d") return now_local, today_str, user_tz def _wrote_today(db, user_id, now_local, user_tz) -> bool: 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) return db.entries.count_documents({ "userId": user_id, "createdAt": {"$gte": today_start_utc, "$lt": today_end_utc}, }) > 0 def _process_user(db, user: dict, now_utc: datetime): uid = user.get("_id") reminder = user.get("reminder", {}) reminder_time_str = reminder.get("time") timezone_str = reminder.get("timezone", "UTC") fcm_tokens: list = user.get("fcmTokens", []) if not reminder_time_str or not fcm_tokens: return now_local, today_str, user_tz = _get_user_local_time(now_utc, timezone_str) current_hm = now_local.strftime("%H:%M") if current_hm != reminder_time_str: log.debug(f"User {uid}: skipped — current time {current_hm} != reminder time {reminder_time_str} ({timezone_str})") return if _wrote_today(db, uid, now_local, user_tz): log.debug(f"User {uid}: skipped — already wrote today") return log.info(f"User {uid}: sending reminder (time={reminder_time_str}, tz={timezone_str})") _send_push(uid, fcm_tokens, db) def _process_universal(db, user: dict, now_utc: datetime): """Universal 11pm reminder — fires if enabled and no entry written today.""" uid = user.get("_id") reminder = user.get("reminder", {}) timezone_str = reminder.get("timezone", "UTC") fcm_tokens: list = user.get("fcmTokens", []) if not fcm_tokens: return now_local, today_str, user_tz = _get_user_local_time(now_utc, timezone_str) if now_local.strftime("%H:%M") != "23:00": return if reminder.get("lastUniversalDate") == today_str: log.debug(f"User {uid}: universal reminder skipped — already sent today") return if _wrote_today(db, uid, now_local, user_tz): log.debug(f"User {uid}: universal reminder skipped — already wrote today") db.users.update_one({"_id": uid}, {"$set": {"reminder.lastUniversalDate": today_str}}) return log.info(f"User {uid}: sending universal 11pm reminder (tz={timezone_str})") _send_push(uid, fcm_tokens, db, universal=True) db.users.update_one({"_id": uid}, {"$set": {"reminder.lastUniversalDate": today_str}}) def _send_push(user_id, tokens: list, db, universal: bool = False): """Send FCM multicast and prune stale tokens.""" title = "Last chance to journal today 🌙" if universal else "Time to journal 🌱" message = messaging.MulticastMessage( notification=messaging.Notification( title=title, 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") 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}") 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