UMBRA/backend/app/routers/dashboard.py
Kyle Pope be1fdc4551 Calendar backend optimisations: safety caps, shared calendar fix, query consolidation
Phase 1: Recurrence safety — MAX_OCCURRENCES=730 hard cap, adaptive 90-day
horizon for daily events (interval<7), RecurrenceRule cross-field validation,
ID bounds on location_id/calendar_id schemas.

Phase 2: Dashboard correctness — shared calendar events now included in
/dashboard and /upcoming via get_accessible_calendar_ids helper. Project stats
consolidated into single GROUP BY query (saves 1 DB round-trip).

Phase 3: Write performance — bulk db.add_all() for child events, removed
redundant SELECT in this_and_future delete path.

Phase 4: Frontend query efficiency — staleTime: 30_000 on calendar events
query eliminates redundant refetches on mount/view switch. Backend LIMIT 2000
safety guard on events endpoint.

Phase 5: Rate limiting — nginx limit_req zone on /api/events (30r/m) to
prevent DB flooding via recurrence amplification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:31:48 +08:00

259 lines
9.7 KiB
Python

from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_, case
from datetime import datetime, date, timedelta
from typing import Optional, List, Dict, Any
from app.database import get_db
from app.models.settings import Settings
from app.models.todo import Todo
from app.models.calendar_event import CalendarEvent
from app.models.reminder import Reminder
from app.models.project import Project
from app.models.user import User
from app.routers.auth import get_current_user, get_current_settings
from app.services.calendar_sharing import get_accessible_calendar_ids
router = APIRouter()
# Reusable filter: exclude parent template events (they have a real recurrence_rule).
# Some legacy events have "" instead of NULL, so allow both.
_not_parent_template = or_(
CalendarEvent.recurrence_rule == None,
CalendarEvent.recurrence_rule == "",
)
@router.get("/dashboard")
async def get_dashboard(
client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
):
"""Get aggregated dashboard data."""
today = client_date or date.today()
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
# Fetch all accessible calendar IDs (owned + accepted shared memberships)
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
# Today's events (exclude parent templates — they are hidden, children are shown)
today_start = datetime.combine(today, datetime.min.time())
today_end = datetime.combine(today, datetime.max.time())
events_query = select(CalendarEvent).where(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.start_datetime >= today_start,
CalendarEvent.start_datetime <= today_end,
_not_parent_template,
)
events_result = await db.execute(events_query)
todays_events = events_result.scalars().all()
# Upcoming todos (not completed, with due date from today through upcoming_days)
todos_query = select(Todo).where(
Todo.user_id == current_user.id,
Todo.completed == False,
Todo.due_date.isnot(None),
Todo.due_date >= today,
Todo.due_date <= upcoming_cutoff
).order_by(Todo.due_date.asc())
todos_result = await db.execute(todos_query)
upcoming_todos = todos_result.scalars().all()
# Active reminders (not dismissed, is_active = true, from today onward)
reminders_query = select(Reminder).where(
Reminder.user_id == current_user.id,
Reminder.is_active == True,
Reminder.is_dismissed == False,
Reminder.remind_at >= today_start
).order_by(Reminder.remind_at.asc())
reminders_result = await db.execute(reminders_query)
active_reminders = reminders_result.scalars().all()
# Project stats — single GROUP BY query, derive total in Python
projects_by_status_result = await db.execute(
select(
Project.status,
func.count(Project.id).label("count"),
).where(Project.user_id == current_user.id).group_by(Project.status)
)
projects_by_status = {row[0]: row[1] for row in projects_by_status_result}
total_projects = sum(projects_by_status.values())
# Todo counts: total and incomplete in a single query
todo_counts_result = await db.execute(
select(
func.count(Todo.id).label("total"),
func.count(case((Todo.completed == False, Todo.id))).label("incomplete"),
).where(Todo.user_id == current_user.id)
)
todo_row = todo_counts_result.one()
total_todos = todo_row.total
total_incomplete_todos = todo_row.incomplete
# Starred events (within upcoming_days window, ordered by date, scoped to user's calendars)
upcoming_cutoff_dt = datetime.combine(upcoming_cutoff, datetime.max.time())
starred_query = select(CalendarEvent).where(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.is_starred == True,
CalendarEvent.start_datetime > today_start,
CalendarEvent.start_datetime <= upcoming_cutoff_dt,
_not_parent_template,
).order_by(CalendarEvent.start_datetime.asc()).limit(5)
starred_result = await db.execute(starred_query)
starred_events = starred_result.scalars().all()
starred_events_data = [
{
"id": e.id,
"title": e.title,
"start_datetime": e.start_datetime
}
for e in starred_events
]
return {
"todays_events": [
{
"id": event.id,
"title": event.title,
"start_datetime": event.start_datetime,
"end_datetime": event.end_datetime,
"all_day": event.all_day,
"color": event.color,
"is_starred": event.is_starred
}
for event in todays_events
],
"upcoming_todos": [
{
"id": todo.id,
"title": todo.title,
"due_date": todo.due_date,
"priority": todo.priority,
"category": todo.category
}
for todo in upcoming_todos
],
"active_reminders": [
{
"id": reminder.id,
"title": reminder.title,
"remind_at": reminder.remind_at
}
for reminder in active_reminders
],
"project_stats": {
"total": total_projects,
"by_status": projects_by_status
},
"total_incomplete_todos": total_incomplete_todos,
"total_todos": total_todos,
"starred_events": starred_events_data
}
@router.get("/upcoming")
async def get_upcoming(
days: int = Query(default=7, ge=1, le=90),
client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_settings: Settings = Depends(get_current_settings),
):
"""Get unified list of upcoming items (todos, events, reminders) sorted by date."""
today = client_date or date.today()
cutoff_date = today + timedelta(days=days)
cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time())
today_start = datetime.combine(today, datetime.min.time())
overdue_floor = today - timedelta(days=30)
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
# Fetch all accessible calendar IDs (owned + accepted shared memberships)
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
# Build queries — include overdue todos (up to 30 days back) and snoozed reminders
todos_query = select(Todo).where(
Todo.user_id == current_user.id,
Todo.completed == False,
Todo.due_date.isnot(None),
Todo.due_date >= overdue_floor,
Todo.due_date <= cutoff_date
)
events_query = select(CalendarEvent).where(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.start_datetime >= today_start,
CalendarEvent.start_datetime <= cutoff_datetime,
_not_parent_template,
)
reminders_query = select(Reminder).where(
Reminder.user_id == current_user.id,
Reminder.is_active == True,
Reminder.is_dismissed == False,
Reminder.remind_at >= overdue_floor_dt,
Reminder.remind_at <= cutoff_datetime
)
# Execute queries sequentially (single session cannot run concurrent queries)
todos_result = await db.execute(todos_query)
todos = todos_result.scalars().all()
events_result = await db.execute(events_query)
events = events_result.scalars().all()
reminders_result = await db.execute(reminders_query)
reminders = reminders_result.scalars().all()
# Combine into unified list
upcoming_items: List[Dict[str, Any]] = []
for todo in todos:
upcoming_items.append({
"type": "todo",
"id": todo.id,
"title": todo.title,
"date": todo.due_date.isoformat() if todo.due_date else None,
"datetime": None,
"priority": todo.priority,
"category": todo.category,
"is_overdue": todo.due_date < today if todo.due_date else False,
})
for event in events:
end_dt = event.end_datetime
upcoming_items.append({
"type": "event",
"id": event.id,
"title": event.title,
"date": event.start_datetime.date().isoformat(),
"datetime": event.start_datetime.isoformat(),
"end_datetime": end_dt.isoformat() if end_dt else None,
"all_day": event.all_day,
"color": event.color,
"is_starred": event.is_starred,
})
for reminder in reminders:
remind_at_date = reminder.remind_at.date() if reminder.remind_at else None
upcoming_items.append({
"type": "reminder",
"id": reminder.id,
"title": reminder.title,
"date": remind_at_date.isoformat() if remind_at_date else None,
"datetime": reminder.remind_at.isoformat() if reminder.remind_at else None,
"snoozed_until": reminder.snoozed_until.isoformat() if reminder.snoozed_until else None,
"is_overdue": remind_at_date < today if remind_at_date else False,
})
# Sort by date/datetime
upcoming_items.sort(key=lambda x: x["datetime"] if x["datetime"] else x["date"])
return {
"items": upcoming_items,
"days": days,
"cutoff_date": cutoff_date.isoformat()
}