diff --git a/backend/alembic/versions/035_add_performance_indexes.py b/backend/alembic/versions/035_add_performance_indexes.py new file mode 100644 index 0000000..c7fb34b --- /dev/null +++ b/backend/alembic/versions/035_add_performance_indexes.py @@ -0,0 +1,64 @@ +"""Add performance indexes for hot query paths. + +Covers: +- calendar_events range queries scoped by calendar (dashboard, notifications) +- calendar_events starred query (dashboard widget) +- calendar_events parent_event_id (recurring series DELETE) +- user_sessions lookup (auth middleware, every request) +- ntfy_sent purge query (background job, every 60s) + +Revision ID: 035 +Revises: 034 +Create Date: 2026-02-27 +""" +from alembic import op + +revision = "035" +down_revision = "034" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Composite index for event range queries scoped by calendar + op.create_index( + "ix_calendar_events_calendar_start_end", + "calendar_events", + ["calendar_id", "start_datetime", "end_datetime"], + ) + + # Partial index for starred events dashboard query + op.create_index( + "ix_calendar_events_calendar_starred", + "calendar_events", + ["calendar_id", "is_starred"], + ) + + # FK lookup index for recurring children DELETE + op.create_index( + "ix_calendar_events_parent_id", + "calendar_events", + ["parent_event_id"], + ) + + # Composite index for session validation (runs on every authenticated request) + op.create_index( + "ix_user_sessions_lookup", + "user_sessions", + ["user_id", "revoked", "expires_at"], + ) + + # Index for ntfy_sent purge query (DELETE WHERE sent_at < cutoff) + op.create_index( + "ix_ntfy_sent_sent_at", + "ntfy_sent", + ["sent_at"], + ) + + +def downgrade() -> None: + op.drop_index("ix_ntfy_sent_sent_at", table_name="ntfy_sent") + op.drop_index("ix_user_sessions_lookup", table_name="user_sessions") + op.drop_index("ix_calendar_events_parent_id", table_name="calendar_events") + op.drop_index("ix_calendar_events_calendar_starred", table_name="calendar_events") + op.drop_index("ix_calendar_events_calendar_start_end", table_name="calendar_events") diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index 595191a..045a4ab 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -40,14 +40,12 @@ UMBRA_URL = "http://10.0.69.35" # ── Dedup helpers ───────────────────────────────────────────────────────────── -async def _already_sent(db: AsyncSession, key: str, user_id: int) -> bool: +async def _get_sent_keys(db: AsyncSession, user_id: int) -> set[str]: + """Batch-fetch all notification keys for a user in one query.""" result = await db.execute( - select(NtfySent).where( - NtfySent.user_id == user_id, - NtfySent.notification_key == key, - ) + select(NtfySent.notification_key).where(NtfySent.user_id == user_id) ) - return result.scalar_one_or_none() is not None + return set(result.scalars().all()) async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None: @@ -57,7 +55,7 @@ async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None: # ── Dispatch functions ──────────────────────────────────────────────────────── -async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime) -> None: +async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetime, sent_keys: set[str]) -> None: """Send notifications for reminders that are currently due and not dismissed/snoozed.""" # Mirror the filter from /api/reminders/due, scoped to this user result = await db.execute( @@ -79,7 +77,7 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim # Key includes user_id to prevent cross-user dedup collisions key = f"reminder:{settings.user_id}:{reminder.id}:{reminder.remind_at.date()}" - if await _already_sent(db, key, settings.user_id): + if key in sent_keys: continue payload = build_reminder_notification( @@ -95,9 +93,10 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim ) if sent: await _mark_sent(db, key, settings.user_id) + sent_keys.add(key) -async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) -> None: +async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime, sent_keys: set[str]) -> None: """Send notifications for calendar events within the configured lead time window.""" lead_minutes = settings.ntfy_event_lead_minutes # Window: events starting between now and (now + lead_minutes) @@ -127,7 +126,7 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) for event in events: # Key includes user_id to prevent cross-user dedup collisions key = f"event:{settings.user_id}:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}" - if await _already_sent(db, key, settings.user_id): + if key in sent_keys: continue payload = build_event_notification( @@ -146,9 +145,10 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) ) if sent: await _mark_sent(db, key, settings.user_id) + sent_keys.add(key) -async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: +async def _dispatch_todos(db: AsyncSession, settings: Settings, today, sent_keys: set[str]) -> None: """Send notifications for incomplete todos due within the configured lead days.""" lead_days = settings.ntfy_todo_lead_days cutoff = today + timedelta(days=lead_days) @@ -168,7 +168,7 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: for todo in todos: # Key includes user_id to prevent cross-user dedup collisions key = f"todo:{settings.user_id}:{todo.id}:{today}" - if await _already_sent(db, key, settings.user_id): + if key in sent_keys: continue payload = build_todo_notification( @@ -185,9 +185,10 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None: ) if sent: await _mark_sent(db, key, settings.user_id) + sent_keys.add(key) -async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> None: +async def _dispatch_projects(db: AsyncSession, settings: Settings, today, sent_keys: set[str]) -> None: """Send notifications for projects with deadlines within the configured lead days.""" lead_days = settings.ntfy_project_lead_days cutoff = today + timedelta(days=lead_days) @@ -207,7 +208,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non for project in projects: # Key includes user_id to prevent cross-user dedup collisions key = f"project:{settings.user_id}:{project.id}:{today}" - if await _already_sent(db, key, settings.user_id): + if key in sent_keys: continue payload = build_project_notification( @@ -223,18 +224,22 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non ) if sent: await _mark_sent(db, key, settings.user_id) + sent_keys.add(key) async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime) -> None: """Run all notification dispatches for a single user's settings.""" + # Batch-fetch all sent keys once per user instead of one query per entity + sent_keys = await _get_sent_keys(db, settings.user_id) + if settings.ntfy_reminders_enabled: - await _dispatch_reminders(db, settings, now) + await _dispatch_reminders(db, settings, now, sent_keys) if settings.ntfy_events_enabled: - await _dispatch_events(db, settings, now) + await _dispatch_events(db, settings, now, sent_keys) if settings.ntfy_todos_enabled: - await _dispatch_todos(db, settings, now.date()) + await _dispatch_todos(db, settings, now.date(), sent_keys) if settings.ntfy_projects_enabled: - await _dispatch_projects(db, settings, now.date()) + await _dispatch_projects(db, settings, now.date(), sent_keys) async def _purge_old_sent_records(db: AsyncSession) -> None: diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index c3b3bbc..18190db 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -26,7 +26,7 @@ _not_parent_template = or_( @router.get("/dashboard") async def get_dashboard( - client_date: Optional[date] = Query(None), + 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), @@ -94,11 +94,10 @@ async def get_dashboard( total_incomplete_todos = total_incomplete_result.scalar() # Starred events (upcoming, ordered by date, scoped to user's calendars) - now = datetime.now() starred_query = select(CalendarEvent).where( CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.is_starred == True, - CalendarEvent.start_datetime > now, + CalendarEvent.start_datetime > today_start, _not_parent_template, ).order_by(CalendarEvent.start_datetime.asc()).limit(5) starred_result = await db.execute(starred_query) @@ -156,7 +155,7 @@ async def get_dashboard( @router.get("/upcoming") async def get_upcoming( days: int = Query(default=7, ge=1, le=90), - client_date: Optional[date] = Query(None), + 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), diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 3dc6e1a..e36c198 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -130,8 +130,8 @@ async def _verify_calendar_ownership(db: AsyncSession, calendar_id: int, user_id @router.get("/", response_model=None) async def get_events( - start: Optional[date] = Query(None), - end: Optional[date] = Query(None), + start: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)), + end: 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), ) -> List[Any]: diff --git a/backend/app/routers/todos.py b/backend/app/routers/todos.py index 86cdf65..d5e1d57 100644 --- a/backend/app/routers/todos.py +++ b/backend/app/routers/todos.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ +from sqlalchemy import select, and_, func from typing import Optional, List from datetime import datetime, date, timedelta import calendar @@ -81,6 +81,20 @@ async def _reactivate_recurring_todos(db: AsyncSession, user_id: int) -> None: Scoped to a single user to avoid cross-user reactivation. """ now = datetime.now() + + # Fast-path: skip the FOR UPDATE lock when nothing needs reactivation (common case) + count = await db.scalar( + select(func.count()).select_from(Todo).where( + Todo.user_id == user_id, + Todo.completed == True, # noqa: E712 + Todo.recurrence_rule.isnot(None), + Todo.reset_at.isnot(None), + Todo.reset_at <= now, + ) + ) + if count == 0: + return + query = select(Todo).where( and_( Todo.user_id == user_id, diff --git a/frontend/src/components/admin/AdminDashboardPage.tsx b/frontend/src/components/admin/AdminDashboardPage.tsx index 7881759..0b5cd04 100644 --- a/frontend/src/components/admin/AdminDashboardPage.tsx +++ b/frontend/src/components/admin/AdminDashboardPage.tsx @@ -12,42 +12,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useAdminDashboard, useAuditLog } from '@/hooks/useAdmin'; import { getRelativeTime } from '@/lib/date-utils'; import { cn } from '@/lib/utils'; - -interface StatCardProps { - icon: React.ReactNode; - label: string; - value: string | number; - iconBg?: string; -} - -function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) { - return ( - - -
-
{icon}
-
-

{label}

-

{value}

-
-
-
-
- ); -} - -function actionColor(action: string): string { - if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) { - return 'bg-red-500/15 text-red-400'; - } - if (action.includes('login') || action.includes('create') || action.includes('enabled')) { - return 'bg-green-500/15 text-green-400'; - } - if (action.includes('config') || action.includes('role') || action.includes('password')) { - return 'bg-orange-500/15 text-orange-400'; - } - return 'bg-blue-500/15 text-blue-400'; -} +import { StatCard, actionColor } from './shared'; export default function AdminDashboardPage() { const { data: dashboard, isLoading } = useAdminDashboard(); diff --git a/frontend/src/components/admin/ConfigPage.tsx b/frontend/src/components/admin/ConfigPage.tsx index 8a9ce15..0c1460b 100644 --- a/frontend/src/components/admin/ConfigPage.tsx +++ b/frontend/src/components/admin/ConfigPage.tsx @@ -13,6 +13,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useAuditLog } from '@/hooks/useAdmin'; import { getRelativeTime } from '@/lib/date-utils'; import { cn } from '@/lib/utils'; +import { actionColor } from './shared'; const ACTION_TYPES = [ 'user.create', @@ -39,19 +40,6 @@ function actionLabel(action: string): string { .join(' — '); } -function actionColor(action: string): string { - if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) { - return 'bg-red-500/15 text-red-400'; - } - if (action.includes('login') || action.includes('create') || action.includes('enabled')) { - return 'bg-green-500/15 text-green-400'; - } - if (action.includes('config') || action.includes('role') || action.includes('password')) { - return 'bg-orange-500/15 text-orange-400'; - } - return 'bg-blue-500/15 text-blue-400'; -} - export default function ConfigPage() { const [page, setPage] = useState(1); const [filterAction, setFilterAction] = useState(''); diff --git a/frontend/src/components/admin/IAMPage.tsx b/frontend/src/components/admin/IAMPage.tsx index 64329b9..f7fc357 100644 --- a/frontend/src/components/admin/IAMPage.tsx +++ b/frontend/src/components/admin/IAMPage.tsx @@ -2,11 +2,9 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { Users, - UserCheck, ShieldCheck, Smartphone, Plus, - Loader2, Activity, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -14,6 +12,7 @@ import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; +import { StatCard } from './shared'; import { useAdminUsers, useAdminDashboard, @@ -52,31 +51,6 @@ function RoleBadge({ role }: { role: UserRole }) { ); } -// ── Stat card ───────────────────────────────────────────────────────────────── - -interface StatCardProps { - icon: React.ReactNode; - label: string; - value: string | number; - iconBg?: string; -} - -function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) { - return ( - - -
-
{icon}
-
-

{label}

-

{value}

-
-
-
-
- ); -} - // ── Main page ───────────────────────────────────────────────────────────────── export default function IAMPage() { diff --git a/frontend/src/components/admin/UserActionsMenu.tsx b/frontend/src/components/admin/UserActionsMenu.tsx index d6b4a7b..8c664a6 100644 --- a/frontend/src/components/admin/UserActionsMenu.tsx +++ b/frontend/src/components/admin/UserActionsMenu.tsx @@ -3,13 +3,11 @@ import { toast } from 'sonner'; import { MoreHorizontal, ShieldCheck, - ShieldOff, KeyRound, UserX, UserCheck, LogOut, Smartphone, - SmartphoneOff, ChevronRight, Loader2, } from 'lucide-react'; @@ -208,7 +206,7 @@ export default function UserActionsMenu({ user }: UserActionsMenuProps) { ) } > - + Remove MFA Enforcement ) : ( @@ -233,7 +231,7 @@ export default function UserActionsMenu({ user }: UserActionsMenuProps) { )} onClick={disableMfaConfirm.handleClick} > - + {disableMfaConfirm.confirming ? 'Sure? Click to confirm' : 'Disable MFA'} )} diff --git a/frontend/src/components/admin/shared.tsx b/frontend/src/components/admin/shared.tsx new file mode 100644 index 0000000..0592f87 --- /dev/null +++ b/frontend/src/components/admin/shared.tsx @@ -0,0 +1,42 @@ +import { Card, CardContent } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; + +// ── StatCard ───────────────────────────────────────────────────────────────── + +interface StatCardProps { + icon: React.ReactNode; + label: string; + value: string | number; + iconBg?: string; +} + +export function StatCard({ icon, label, value, iconBg = 'bg-accent/10' }: StatCardProps) { + return ( + + +
+
{icon}
+
+

{label}

+

{value}

+
+
+
+
+ ); +} + +// ── actionColor ────────────────────────────────────────────────────────────── + +export function actionColor(action: string): string { + if (action.includes('failed') || action.includes('locked') || action.includes('disabled')) { + return 'bg-red-500/15 text-red-400'; + } + if (action.includes('login') || action.includes('create') || action.includes('enabled')) { + return 'bg-green-500/15 text-green-400'; + } + if (action.includes('config') || action.includes('role') || action.includes('password')) { + return 'bg-orange-500/15 text-orange-400'; + } + return 'bg-blue-500/15 text-blue-400'; +}