Files
grateful-journal/backend/routers/entries.py
2026-03-24 10:48:20 +05:30

373 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 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)}")