Files
grateful-journal/backend/routers/entries.py
2026-03-09 10:54:07 +05:30

355 lines
11 KiB
Python

"""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 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)
# 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:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
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)
# 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 Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
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)
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 Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(status_code=400, detail="Invalid ID format")
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)
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 Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(status_code=400, detail="Invalid ID format")
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)
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 Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(status_code=400, detail="Invalid ID format")
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)
# 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 Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
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)
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 Exception as e:
if "invalid ObjectId" in str(e).lower():
raise HTTPException(
status_code=400, detail="Invalid user ID format")
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 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)}")