"""Journal entry routes""" import logging from fastapi import APIRouter, HTTPException, Query, Depends from db import get_database from models import JournalEntryCreate, JournalEntryUpdate from datetime import datetime, timedelta from auth import get_current_user, verify_user_access from utils import format_ist_timestamp log = logging.getLogger(__name__) router = APIRouter() def _format_entry(entry: dict) -> dict: """Helper to format entry document for API response.""" return { "id": str(entry["_id"]), "userId": str(entry["userId"]), "title": entry.get("title"), "content": entry.get("content"), "mood": entry.get("mood"), "tags": entry.get("tags", []), "isPublic": entry.get("isPublic", False), "entryDate": entry.get("entryDate", entry.get("createdAt")).isoformat() if entry.get("entryDate") or entry.get("createdAt") else None, "createdAt": entry["createdAt"].isoformat(), "updatedAt": entry["updatedAt"].isoformat(), "encryption": entry.get("encryption") } @router.post("/{user_id}", response_model=dict) async def create_entry(user_id: str, entry_data: JournalEntryCreate, token: dict = Depends(get_current_user)): """ Create a new journal entry. For encrypted entries: - Send encryption metadata with ciphertext and nonce - Omit title and content (they're encrypted in ciphertext) entryDate: The logical journal date for this entry (defaults to today UTC). Server stores only: encrypted ciphertext, nonce, and metadata. Server never sees plaintext. """ db = get_database() try: user = verify_user_access(user_id, db, token) user_oid = user["_id"] now = datetime.utcnow() entry_date = entry_data.entryDate or now.replace(hour=0, minute=0, second=0, microsecond=0) if entry_data.encryption: if not entry_data.encryption.ciphertext or not entry_data.encryption.nonce: raise HTTPException( status_code=400, detail="Encryption metadata must include ciphertext and nonce" ) entry_doc = { "userId": user_oid, "title": entry_data.title, "content": entry_data.content, "mood": entry_data.mood, "tags": entry_data.tags or [], "isPublic": entry_data.isPublic or False, "entryDate": entry_date, "createdAt": now, "updatedAt": now, "encryption": entry_data.encryption.model_dump() if entry_data.encryption else None } result = db.entries.insert_one(entry_doc) return { "id": str(result.inserted_id), "userId": user_id, "message": "Entry created successfully" } except HTTPException: raise except Exception: log.exception("Failed to create entry") raise HTTPException(status_code=500, detail="Internal server error") @router.get("/{user_id}") async def get_user_entries( user_id: str, limit: int = Query(50, ge=1, le=100), skip: int = Query(0, ge=0), token: dict = Depends(get_current_user) ): """Get paginated entries for a user (most recent first).""" db = get_database() try: user = verify_user_access(user_id, db, token) user_oid = user["_id"] entries = list( db.entries.find({"userId": user_oid}).sort("createdAt", -1).skip(skip).limit(limit) ) formatted_entries = [_format_entry(entry) for entry in entries] total = db.entries.count_documents({"userId": user_oid}) return { "entries": formatted_entries, "pagination": { "total": total, "limit": limit, "skip": skip, "hasMore": (skip + limit) < total } } except HTTPException: raise except Exception: log.exception("Failed to fetch entries") raise HTTPException(status_code=500, detail="Internal server error") @router.get("/{user_id}/{entry_id}") async def get_entry(user_id: str, entry_id: str, token: dict = Depends(get_current_user)): """Get a specific entry by ID.""" from bson import ObjectId from bson.errors import InvalidId db = get_database() try: user = verify_user_access(user_id, db, token) user_oid = user["_id"] try: entry_oid = ObjectId(entry_id) except InvalidId: raise HTTPException(status_code=400, detail="Invalid entry ID format") entry = db.entries.find_one({"_id": entry_oid, "userId": user_oid}) if not entry: raise HTTPException(status_code=404, detail="Entry not found") return _format_entry(entry) except HTTPException: raise except Exception: log.exception("Failed to fetch entry") raise HTTPException(status_code=500, detail="Internal server error") @router.put("/{user_id}/{entry_id}") async def update_entry(user_id: str, entry_id: str, entry_data: JournalEntryUpdate, token: dict = Depends(get_current_user)): """Update a journal entry.""" from bson import ObjectId from bson.errors import InvalidId db = get_database() try: user = verify_user_access(user_id, db, token) user_oid = user["_id"] try: entry_oid = ObjectId(entry_id) except InvalidId: raise HTTPException(status_code=400, detail="Invalid entry ID format") update_data = entry_data.model_dump(exclude_unset=True) update_data["updatedAt"] = datetime.utcnow() if "entryDate" in update_data and isinstance(update_data["entryDate"], str): update_data["entryDate"] = datetime.fromisoformat( update_data["entryDate"].replace("Z", "+00:00")) result = db.entries.update_one( {"_id": entry_oid, "userId": user_oid}, {"$set": update_data} ) if result.matched_count == 0: raise HTTPException(status_code=404, detail="Entry not found") entry = db.entries.find_one({"_id": entry_oid}) return _format_entry(entry) except HTTPException: raise except Exception: log.exception("Failed to update entry") raise HTTPException(status_code=500, detail="Internal server error") @router.delete("/{user_id}/{entry_id}") async def delete_entry(user_id: str, entry_id: str, token: dict = Depends(get_current_user)): """Delete a journal entry.""" from bson import ObjectId from bson.errors import InvalidId db = get_database() try: user = verify_user_access(user_id, db, token) user_oid = user["_id"] try: entry_oid = ObjectId(entry_id) except InvalidId: raise HTTPException(status_code=400, detail="Invalid entry ID format") result = db.entries.delete_one({"_id": entry_oid, "userId": user_oid}) if result.deleted_count == 0: raise HTTPException(status_code=404, detail="Entry not found") return {"message": "Entry deleted successfully"} except HTTPException: raise except Exception: log.exception("Failed to delete entry") raise HTTPException(status_code=500, detail="Internal server error") @router.get("/{user_id}/by-date/{date_str}") async def get_entries_by_date(user_id: str, date_str: str, token: dict = Depends(get_current_user)): """Get entries for a specific date (format: YYYY-MM-DD).""" db = get_database() try: user = verify_user_access(user_id, db, token) user_oid = user["_id"] try: target_date = datetime.strptime(date_str, "%Y-%m-%d") except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") next_date = target_date + timedelta(days=1) entries = list( db.entries.find({ "userId": user_oid, "entryDate": {"$gte": target_date, "$lt": next_date} }).sort("createdAt", -1) ) return { "entries": [_format_entry(e) for e in entries], "date": date_str, "count": len(entries) } except HTTPException: raise except Exception: log.exception("Failed to fetch entries by date") raise HTTPException(status_code=500, detail="Internal server error") @router.get("/{user_id}/by-month/{year}/{month}") async def get_entries_by_month( user_id: str, year: int, month: int, limit: int = Query(100, ge=1, le=500), token: dict = Depends(get_current_user) ): """Get entries for a specific month (for calendar view).""" db = get_database() try: user = verify_user_access(user_id, db, token) user_oid = user["_id"] if not (1 <= month <= 12): raise HTTPException(status_code=400, detail="Month must be between 1 and 12") start_date = datetime(year, month, 1) end_date = datetime(year + 1, 1, 1) if month == 12 else datetime(year, month + 1, 1) entries = list( db.entries.find({ "userId": user_oid, "entryDate": {"$gte": start_date, "$lt": end_date} }).sort("entryDate", -1).limit(limit) ) return { "entries": [_format_entry(e) for e in entries], "year": year, "month": month, "count": len(entries) } except HTTPException: raise except Exception: log.exception("Failed to fetch entries by month") raise HTTPException(status_code=500, detail="Internal server error") @router.post("/convert-timestamp/utc-to-ist") async def convert_utc_to_ist(data: dict): """Convert UTC ISO timestamp to IST (Indian Standard Time).""" try: utc_timestamp = data.get("timestamp") if not utc_timestamp: raise HTTPException(status_code=400, detail="Missing 'timestamp' field") ist_timestamp = format_ist_timestamp(utc_timestamp) return {"utc": utc_timestamp, "ist": ist_timestamp} except HTTPException: raise except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception: log.exception("Timestamp conversion failed") raise HTTPException(status_code=500, detail="Internal server error")