303 lines
10 KiB
Python
303 lines
10 KiB
Python
"""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")
|