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 (
-
-
-
-
-
- );
-}
-
-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 (
-
-
-
-
-
- );
-}
-
// ── 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 (
+
+
+
+
+
+ );
+}
+
+// ── 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';
+}