180 lines
5.8 KiB
Python
180 lines
5.8 KiB
Python
"""
|
|
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
|