seo improvement and updated notifs

This commit is contained in:
2026-04-13 12:27:30 +05:30
parent df4bb88f70
commit 34254f94f9
26 changed files with 941 additions and 58 deletions

View File

@@ -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=

View File

@@ -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",

View File

@@ -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")

View File

@@ -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

View 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
View 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