"""Journal entry routes""" from fastapi import APIRouter, HTTPException, Query from db import get_database from models import JournalEntryCreate, JournalEntryUpdate, JournalEntry, EntriesListResponse, PaginationMeta from datetime import datetime, timedelta from typing import List, Optional from bson import ObjectId from bson.errors import InvalidId from utils import format_ist_timestamp 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"), # None if encrypted "content": entry.get("content"), # None if encrypted "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(), # Full encryption metadata including ciphertext and nonce "encryption": entry.get("encryption") } @router.post("/{user_id}", response_model=dict) async def create_entry(user_id: str, entry_data: JournalEntryCreate): """ Create a new journal entry. For encrypted entries: - Send encryption metadata with ciphertext and nonce - Omit title and content (they're encrypted in ciphertext) For unencrypted entries (deprecated): - Send title and content directly entryDate: The logical journal date for this entry (defaults to today UTC). createdAt: Database write timestamp. Server stores only: encrypted ciphertext, nonce, and metadata. Server never sees plaintext. """ db = get_database() try: user_oid = ObjectId(user_id) except InvalidId: raise HTTPException(status_code=400, detail="Invalid user ID format") try: # Verify user exists user = db.users.find_one({"_id": user_oid}) if not user: raise HTTPException(status_code=404, detail="User not found") now = datetime.utcnow() entry_date = entry_data.entryDate or now.replace( hour=0, minute=0, second=0, microsecond=0) # Validate encryption metadata if present 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, # None if encrypted "content": entry_data.content, # None if encrypted "mood": entry_data.mood, "tags": entry_data.tags or [], "isPublic": entry_data.isPublic or False, "entryDate": entry_date, # Logical journal 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 as e: raise HTTPException( status_code=500, detail=f"Failed to create entry: {str(e)}") @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) ): """ Get paginated entries for a user (most recent first). Supports pagination via skip and limit. """ db = get_database() try: user_oid = ObjectId(user_id) except InvalidId: raise HTTPException(status_code=400, detail="Invalid user ID format") try: # Verify user exists user = db.users.find_one({"_id": user_oid}) if not user: raise HTTPException(status_code=404, detail="User not found") # Get entries entries = list( db.entries.find( {"userId": user_oid} ).sort("createdAt", -1).skip(skip).limit(limit) ) # Format entries formatted_entries = [_format_entry(entry) for entry in entries] # Get total count total = db.entries.count_documents({"userId": user_oid}) has_more = (skip + limit) < total return { "entries": formatted_entries, "pagination": { "total": total, "limit": limit, "skip": skip, "hasMore": has_more } } except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"Failed to fetch entries: {str(e)}") @router.get("/{user_id}/{entry_id}") async def get_entry(user_id: str, entry_id: str): """Get a specific entry by ID.""" db = get_database() try: user_oid = ObjectId(user_id) entry_oid = ObjectId(entry_id) except InvalidId: raise HTTPException(status_code=400, detail="Invalid ID format") try: 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 as e: raise HTTPException( status_code=500, detail=f"Failed to fetch entry: {str(e)}") @router.put("/{user_id}/{entry_id}") async def update_entry(user_id: str, entry_id: str, entry_data: JournalEntryUpdate): """Update a journal entry.""" db = get_database() try: user_oid = ObjectId(user_id) entry_oid = ObjectId(entry_id) except InvalidId: raise HTTPException(status_code=400, detail="Invalid ID format") try: update_data = entry_data.model_dump(exclude_unset=True) update_data["updatedAt"] = datetime.utcnow() # If entryDate provided in update data, ensure it's a datetime 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") # Fetch and return updated entry entry = db.entries.find_one({"_id": entry_oid}) return _format_entry(entry) except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"Failed to update entry: {str(e)}") @router.delete("/{user_id}/{entry_id}") async def delete_entry(user_id: str, entry_id: str): """Delete a journal entry.""" db = get_database() try: user_oid = ObjectId(user_id) entry_oid = ObjectId(entry_id) except InvalidId: raise HTTPException(status_code=400, detail="Invalid ID format") try: 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 as e: raise HTTPException( status_code=500, detail=f"Failed to delete entry: {str(e)}") @router.get("/{user_id}/by-date/{date_str}") async def get_entries_by_date(user_id: str, date_str: str): """ Get entries for a specific date (format: YYYY-MM-DD). Matches entries by entryDate field. """ db = get_database() try: user_oid = ObjectId(user_id) except InvalidId: raise HTTPException(status_code=400, detail="Invalid user ID format") try: # Parse date target_date = datetime.strptime(date_str, "%Y-%m-%d") 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) ) formatted_entries = [_format_entry(entry) for entry in entries] return { "entries": formatted_entries, "date": date_str, "count": len(formatted_entries) } except ValueError: raise HTTPException( status_code=400, detail="Invalid date format. Use YYYY-MM-DD") except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"Failed to fetch entries: {str(e)}") @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)): """ Get entries for a specific month (for calendar view). Query format: GET /api/entries/{user_id}/by-month/{year}/{month}?limit=100 """ db = get_database() try: user_oid = ObjectId(user_id) except InvalidId: raise HTTPException(status_code=400, detail="Invalid user ID format") try: if not (1 <= month <= 12): raise HTTPException( status_code=400, detail="Month must be between 1 and 12") # Calculate date range start_date = datetime(year, month, 1) if month == 12: end_date = datetime(year + 1, 1, 1) else: end_date = 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) ) formatted_entries = [_format_entry(entry) for entry in entries] return { "entries": formatted_entries, "year": year, "month": month, "count": len(formatted_entries) } except ValueError: raise HTTPException(status_code=400, detail="Invalid year or month") except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"Failed to fetch entries: {str(e)}") @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 as e: raise HTTPException( status_code=500, detail=f"Conversion failed: {str(e)}")