seo improvement and updated notifs
This commit is contained in:
@@ -8,3 +8,8 @@ FRONTEND_URL=http://localhost:8000
|
||||
# MONGODB_URI=mongodb://mongo:27017
|
||||
# ENVIRONMENT=production
|
||||
|
||||
# Firebase Admin SDK service account (for sending push notifications)
|
||||
# Firebase Console → Project Settings → Service Accounts → Generate new private key
|
||||
# Paste the entire JSON on a single line (escape double quotes if needed):
|
||||
FIREBASE_SERVICE_ACCOUNT_JSON=
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -8,6 +8,8 @@ class Settings(BaseSettings):
|
||||
api_port: int = 8001
|
||||
environment: str = "development"
|
||||
frontend_url: str = "http://localhost:8000"
|
||||
# Firebase Admin SDK service account JSON (paste the full JSON as a single-line string)
|
||||
firebase_service_account_json: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from db import MongoDB, get_database
|
||||
from db import MongoDB
|
||||
from config import get_settings
|
||||
from routers import entries, users
|
||||
from routers import notifications
|
||||
from scheduler import start_scheduler
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
settings = get_settings()
|
||||
_scheduler = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
MongoDB.connect_db()
|
||||
global _scheduler
|
||||
_scheduler = start_scheduler()
|
||||
yield
|
||||
# Shutdown
|
||||
if _scheduler:
|
||||
_scheduler.shutdown(wait=False)
|
||||
MongoDB.close_db()
|
||||
|
||||
app = FastAPI(
|
||||
@@ -43,6 +50,7 @@ app.add_middleware(
|
||||
# Include routers
|
||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
||||
app.include_router(entries.router, prefix="/api/entries", tags=["entries"])
|
||||
app.include_router(notifications.router, prefix="/api/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -5,6 +5,9 @@ pydantic>=2.5.0
|
||||
python-dotenv==1.0.0
|
||||
pydantic-settings>=2.1.0
|
||||
python-multipart==0.0.6
|
||||
firebase-admin>=6.5.0
|
||||
apscheduler>=3.10.4
|
||||
pytz>=2024.1
|
||||
|
||||
# Testing
|
||||
pytest>=7.4.0
|
||||
|
||||
78
backend/routers/notifications.py
Normal file
78
backend/routers/notifications.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Notification routes — FCM token registration and reminder settings."""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from db import get_database
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from bson import ObjectId
|
||||
from bson.errors import InvalidId
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class FcmTokenRequest(BaseModel):
|
||||
userId: str
|
||||
fcmToken: str
|
||||
|
||||
|
||||
class ReminderSettingsRequest(BaseModel):
|
||||
time: Optional[str] = None # "HH:MM" in 24-hour format
|
||||
enabled: bool
|
||||
timezone: Optional[str] = None # IANA timezone, e.g. "Asia/Kolkata"
|
||||
|
||||
|
||||
@router.post("/fcm-token", response_model=dict)
|
||||
async def register_fcm_token(body: FcmTokenRequest):
|
||||
"""
|
||||
Register (or refresh) an FCM device token for a user.
|
||||
Stores unique tokens per user — duplicate tokens are ignored.
|
||||
"""
|
||||
db = get_database()
|
||||
|
||||
try:
|
||||
user_oid = ObjectId(body.userId)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID")
|
||||
|
||||
user = db.users.find_one({"_id": user_oid})
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Add token to set (avoid duplicates)
|
||||
db.users.update_one(
|
||||
{"_id": user_oid},
|
||||
{
|
||||
"$addToSet": {"fcmTokens": body.fcmToken},
|
||||
"$set": {"updatedAt": datetime.utcnow()},
|
||||
}
|
||||
)
|
||||
return {"message": "FCM token registered"}
|
||||
|
||||
|
||||
@router.put("/reminder/{user_id}", response_model=dict)
|
||||
async def update_reminder(user_id: str, settings: ReminderSettingsRequest):
|
||||
"""
|
||||
Save or update daily reminder settings for a user.
|
||||
"""
|
||||
db = get_database()
|
||||
|
||||
try:
|
||||
user_oid = ObjectId(user_id)
|
||||
except InvalidId:
|
||||
raise HTTPException(status_code=400, detail="Invalid user ID")
|
||||
|
||||
user = db.users.find_one({"_id": user_oid})
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
reminder_update: dict = {"reminder.enabled": settings.enabled}
|
||||
if settings.time is not None:
|
||||
reminder_update["reminder.time"] = settings.time
|
||||
if settings.timezone is not None:
|
||||
reminder_update["reminder.timezone"] = settings.timezone
|
||||
|
||||
db.users.update_one(
|
||||
{"_id": user_oid},
|
||||
{"$set": {**reminder_update, "updatedAt": datetime.utcnow()}}
|
||||
)
|
||||
return {"message": "Reminder settings updated"}
|
||||
179
backend/scheduler.py
Normal file
179
backend/scheduler.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user