From dbad9c69b3ce737b3a51a4c0273f85bd6d1d0fc6 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 13 Mar 2026 00:03:46 +0800 Subject: [PATCH 1/6] Phase 1: Docker infrastructure optimization - Add .dockerignore for backend and frontend (DC-1: eliminates node_modules/ and .env from build context) - Delete start.sh with --reload flag (DC-2: superseded by Dockerfile CMD) - Create entrypoint.sh with exec uvicorn (DW-5: proper PID 1 signal handling) - Pin base images to patch-level tags (DW-1: reproducible builds) - Reorder Dockerfile: create appuser before COPY, use --chown (DW-2) - Switch to npm ci for lockfile-enforced installs (DW-3) - Add network segmentation: backend_net + frontend_net (DW-4: db unreachable from frontend container) - Add deploy.resources limits to all services (DW-6: OOM protection) - Refactor proxy-params.conf to include security headers, deduplicate from nginx.conf location blocks (DW-7) - Add image/svg+xml to gzip_types (DS-1) - Add wget healthcheck for frontend service (DS-2) Co-Authored-By: Claude Opus 4.6 --- backend/.dockerignore | 43 ++++++++++++++++++++++++++++++++++++++ backend/Dockerfile | 23 ++++++++++---------- backend/entrypoint.sh | 13 ++++++++++++ backend/start.sh | 9 -------- docker-compose.yaml | 33 +++++++++++++++++++++++++++++ frontend/.dockerignore | 25 ++++++++++++++++++++++ frontend/Dockerfile | 8 +++---- frontend/nginx.conf | 22 +++---------------- frontend/proxy-params.conf | 10 +++++++++ 9 files changed, 143 insertions(+), 43 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/entrypoint.sh delete mode 100644 backend/start.sh create mode 100644 frontend/.dockerignore diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..0e880dc --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,43 @@ +# Version control +.git +.gitignore + +# Python artifacts +__pycache__ +*.pyc +*.pyo +*.egg-info +dist +build +.eggs + +# Virtual environments +.venv +venv +env + +# IDE +.vscode +.idea + +# Environment files — never bake secrets into the image +.env +.env.* + +# Tests +tests +pytest.ini +.pytest_cache +.coverage +htmlcov + +# Documentation +*.md +LICENSE + +# Dev scripts +start.sh + +# Docker files (no need to copy into the image) +Dockerfile +docker-compose*.yaml diff --git a/backend/Dockerfile b/backend/Dockerfile index c80a749..b58fc52 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,5 @@ # ── Build stage: compile C extensions ────────────────────────────────── -FROM python:3.12-slim AS builder +FROM python:3.12.9-slim-bookworm AS builder WORKDIR /build @@ -11,24 +11,25 @@ COPY requirements.txt . RUN pip install --no-cache-dir --prefix=/install -r requirements.txt # ── Runtime stage: lean production image ─────────────────────────────── -FROM python:3.12-slim +FROM python:3.12.9-slim-bookworm + +# Create non-root user first, then copy with correct ownership (DW-2) +RUN useradd -m -u 1000 appuser WORKDIR /app # Copy pre-built Python packages from builder COPY --from=builder /install /usr/local -# Copy application code -COPY . . +# Copy application code with correct ownership — avoids redundant chown layer +COPY --chown=appuser:appuser . . + +# Make entrypoint executable +RUN chmod +x entrypoint.sh -# Create non-root user -RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app USER appuser EXPOSE 8000 -# Run migrations and start server -# --no-server-header: suppresses uvicorn version disclosure -# --proxy-headers: reads X-Forwarded-Proto/For from reverse proxy so redirects use correct scheme -# --forwarded-allow-ips '*': trusts proxy headers from any IP (nginx is on Docker bridge network) -CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-server-header --proxy-headers --forwarded-allow-ips '*'"] +# Use entrypoint with exec so uvicorn runs as PID 1 and receives signals (DW-5) +ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..7edc73b --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +echo "Running database migrations..." +alembic upgrade head + +echo "Starting uvicorn..." +exec uvicorn app.main:app \ + --host 0.0.0.0 \ + --port 8000 \ + --no-server-header \ + --proxy-headers \ + --forwarded-allow-ips '*' diff --git a/backend/start.sh b/backend/start.sh deleted file mode 100644 index 770cff7..0000000 --- a/backend/start.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -# Run database migrations -echo "Running database migrations..." -alembic upgrade head - -# Start the FastAPI application -echo "Starting FastAPI application..." -uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/docker-compose.yaml b/docker-compose.yaml index 1b56b01..616f9af 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,11 +5,18 @@ services: env_file: .env volumes: - postgres_data:/var/lib/postgresql/data + networks: + - backend_net healthcheck: test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"] interval: 5s timeout: 5s retries: 5 + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" backend: build: ./backend @@ -18,11 +25,19 @@ services: depends_on: db: condition: service_healthy + networks: + - backend_net + - frontend_net healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] interval: 10s timeout: 5s retries: 3 + deploy: + resources: + limits: + memory: 512M + cpus: "1.0" frontend: build: ./frontend @@ -32,6 +47,24 @@ services: depends_on: backend: condition: service_healthy + networks: + - frontend_net + healthcheck: + test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/"] + interval: 15s + timeout: 5s + retries: 3 + deploy: + resources: + limits: + memory: 128M + cpus: "0.5" volumes: postgres_data: + +networks: + backend_net: + driver: bridge + frontend_net: + driver: bridge diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..b800409 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,25 @@ +# Dependencies — rebuilt inside the container from lockfile +node_modules + +# Build output — rebuilt inside the container +dist + +# Version control +.git +.gitignore + +# Environment files +.env +.env.* + +# IDE +.vscode +.idea + +# Documentation +*.md +LICENSE + +# Docker files +Dockerfile +docker-compose*.yaml diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ef60a12..88d4008 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,13 +1,13 @@ # Build stage -FROM node:20-alpine AS build +FROM node:20.18-alpine AS build WORKDIR /app # Copy package files COPY package*.json ./ -# Install dependencies -RUN npm install +# Install dependencies from lockfile (DW-3) +RUN npm ci # Copy source files COPY . . @@ -16,7 +16,7 @@ COPY . . RUN npm run build # Production stage — unprivileged nginx (runs as non-root, listens on 8080) -FROM nginxinc/nginx-unprivileged:alpine +FROM nginxinc/nginx-unprivileged:1.27-alpine # Copy built files from build stage COPY --from=build /app/dist /usr/share/nginx/html diff --git a/frontend/nginx.conf b/frontend/nginx.conf index c3936cc..36a506d 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -41,7 +41,7 @@ server { gzip on; gzip_vary on; gzip_min_length 1024; - gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json image/svg+xml; # Block dotfiles (except .well-known for ACME/Let's Encrypt) (PT-04) location ~ /\.(?!well-known) { @@ -122,28 +122,12 @@ server { include /etc/nginx/proxy-params.conf; } - # API proxy + # API proxy (catch-all for non-rate-limited endpoints) location /api { - proxy_pass http://backend:8000; - proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $forwarded_proto; proxy_cache_bypass $http_upgrade; - - # PT-L01: Prevent browser caching of authenticated API responses - add_header Cache-Control "no-store, no-cache, must-revalidate" always; - # Security headers (must be repeated — nginx add_header in a location block - # overrides server-level add_header directives, so all headers must be explicit) - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; + include /etc/nginx/proxy-params.conf; } # SPA fallback - serve index.html for all routes diff --git a/frontend/proxy-params.conf b/frontend/proxy-params.conf index 4b18310..7ff2efb 100644 --- a/frontend/proxy-params.conf +++ b/frontend/proxy-params.conf @@ -4,3 +4,13 @@ proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; + +# Security headers (repeated per location — nginx add_header in a location block +# overrides server-level directives, so all headers must be explicit) +add_header Cache-Control "no-store, no-cache, must-revalidate" always; +add_header X-Frame-Options "SAMEORIGIN" always; +add_header X-Content-Type-Options "nosniff" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always; +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always; From 1f2083ee6142201603600ca21672698ab63a5367 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 13 Mar 2026 00:05:54 +0800 Subject: [PATCH 2/6] Phase 2: Backend critical path optimizations - AC-1: Merge get_current_user into single JOIN query (session + user in one round-trip instead of two sequential queries per request) - AC-2: Wrap all Argon2id hash/verify calls in run_in_executor to avoid blocking the async event loop (~150ms per operation) - AW-7: Add connection pool config (pool_size=10, pool_pre_ping=True, pool_recycle=1800) to prevent connection exhaustion under load - AC-4: Batch-fetch tasks in reorder_tasks with IN clause instead of N sequential queries during Kanban drag operations - AW-4: Bulk NtfySent inserts with single commit per user instead of per-notification commits in the dispatch job Co-Authored-By: Claude Opus 4.6 --- backend/app/database.py | 8 +++-- backend/app/jobs/notifications.py | 5 ++- backend/app/routers/admin.py | 7 +++-- backend/app/routers/auth.py | 52 +++++++++++++++++++------------ backend/app/routers/projects.py | 22 +++++++------ 5 files changed, 60 insertions(+), 34 deletions(-) diff --git a/backend/app/database.py b/backend/app/database.py index 1743bc1..39142db 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -2,11 +2,15 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sess from sqlalchemy.orm import declarative_base from app.config import settings -# Create async engine +# Create async engine with tuned pool (AW-7) engine = create_async_engine( settings.DATABASE_URL, echo=False, - future=True + future=True, + pool_size=10, + max_overflow=5, + pool_pre_ping=True, + pool_recycle=1800, ) # Create async session factory diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index 7798f0c..62dd369 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -56,8 +56,8 @@ async def _get_sent_keys(db: AsyncSession, user_id: int) -> set[str]: async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None: + """Stage a sent record — caller must commit (AW-4: bulk commit per user).""" db.add(NtfySent(notification_key=key, user_id=user_id)) - await db.commit() # ── Dispatch functions ──────────────────────────────────────────────────────── @@ -248,6 +248,9 @@ async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime if settings.ntfy_projects_enabled: await _dispatch_projects(db, settings, now.date(), sent_keys) + # AW-4: Single commit per user instead of per-notification + await db.commit() + async def _purge_old_sent_records(db: AsyncSession) -> None: """Remove ntfy_sent entries older than 7 days to keep the table lean.""" diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index d1357f5..ead0397 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -10,6 +10,7 @@ Security measures implemented: All routes require the `require_admin` dependency (which chains through get_current_user, so the session cookie is always validated). """ +import asyncio import secrets from datetime import datetime from typing import Optional @@ -222,10 +223,11 @@ async def create_user( if email_exists.scalar_one_or_none(): raise HTTPException(status_code=409, detail="Email already in use") + loop = asyncio.get_running_loop() new_user = User( username=data.username, umbral_name=data.username, - password_hash=hash_password(data.password), + password_hash=await loop.run_in_executor(None, hash_password, data.password), role=data.role, email=email, first_name=data.first_name, @@ -341,7 +343,8 @@ async def reset_user_password( raise HTTPException(status_code=404, detail="User not found") temp_password = secrets.token_urlsafe(16) - user.password_hash = hash_password(temp_password) + loop = asyncio.get_running_loop() + user.password_hash = await loop.run_in_executor(None, hash_password, temp_password) user.must_change_password = True user.last_password_change_at = datetime.now() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 0e9c847..71c7b51 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -16,6 +16,7 @@ Security layers: 4. bcrypt→Argon2id transparent upgrade on first login 5. Role-based authorization via require_role() dependency factory """ +import asyncio import uuid from datetime import datetime, timedelta from typing import Optional @@ -101,25 +102,22 @@ async def get_current_user( if user_id is None or session_id is None: raise HTTPException(status_code=401, detail="Malformed session token") - # Verify session is active in DB (covers revocation + expiry) - session_result = await db.execute( - select(UserSession).where( + # AC-1: Single JOIN query for session + user (was 2 sequential queries) + result = await db.execute( + select(UserSession, User) + .join(User, UserSession.user_id == User.id) + .where( UserSession.id == session_id, UserSession.user_id == user_id, UserSession.revoked == False, UserSession.expires_at > datetime.now(), + User.is_active == True, ) ) - db_session = session_result.scalar_one_or_none() - if not db_session: - raise HTTPException(status_code=401, detail="Session has been revoked or expired") - - user_result = await db.execute( - select(User).where(User.id == user_id, User.is_active == True) - ) - user = user_result.scalar_one_or_none() - if not user: - raise HTTPException(status_code=401, detail="User not found or inactive") + row = result.one_or_none() + if not row: + raise HTTPException(status_code=401, detail="Session expired or user inactive") + db_session, user = row.tuple() # L-03: Sliding window renewal — extend session if >1 day has elapsed since # last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day). @@ -299,7 +297,8 @@ async def setup( if user_count.scalar_one() > 0: raise HTTPException(status_code=400, detail="Setup already completed") - password_hash = hash_password(data.password) + loop = asyncio.get_running_loop() + password_hash = await loop.run_in_executor(None, hash_password, data.password) new_user = User( username=data.username, umbral_name=data.username, @@ -352,12 +351,18 @@ async def login( if not user: # M-02: Run Argon2id against a dummy hash so the response time is # indistinguishable from a wrong-password attempt (prevents username enumeration). - verify_password("x", _DUMMY_HASH) + # AC-2: run_in_executor to avoid blocking the event loop (~150ms) + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, verify_password, "x", _DUMMY_HASH) raise HTTPException(status_code=401, detail="Invalid username or password") # M-02: Run password verification BEFORE lockout check so Argon2id always # executes — prevents distinguishing "locked" from "wrong password" via timing. - valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash) + # AC-2: run_in_executor to avoid blocking the event loop + loop = asyncio.get_running_loop() + valid, new_hash = await loop.run_in_executor( + None, verify_password_with_upgrade, data.password, user.password_hash + ) await _check_account_lockout(user) @@ -465,7 +470,8 @@ async def register( if existing_email.scalar_one_or_none(): raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.") - password_hash = hash_password(data.password) + loop = asyncio.get_running_loop() + password_hash = await loop.run_in_executor(None, hash_password, data.password) # SEC-01: Explicit field assignment — never **data.model_dump() new_user = User( username=data.username, @@ -630,7 +636,10 @@ async def verify_password( """ await _check_account_lockout(current_user) - valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash) + loop = asyncio.get_running_loop() + valid, new_hash = await loop.run_in_executor( + None, verify_password_with_upgrade, data.password, current_user.password_hash + ) if not valid: await _record_failed_login(db, current_user) raise HTTPException(status_code=401, detail="Invalid password") @@ -656,7 +665,10 @@ async def change_password( """Change the current user's password. Requires old password verification.""" await _check_account_lockout(current_user) - valid, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash) + loop = asyncio.get_running_loop() + valid, _ = await loop.run_in_executor( + None, verify_password_with_upgrade, data.old_password, current_user.password_hash + ) if not valid: await _record_failed_login(db, current_user) raise HTTPException(status_code=401, detail="Invalid current password") @@ -664,7 +676,7 @@ async def change_password( if data.new_password == data.old_password: raise HTTPException(status_code=400, detail="New password must be different from your current password") - current_user.password_hash = hash_password(data.new_password) + current_user.password_hash = await loop.run_in_executor(None, hash_password, data.new_password) current_user.last_password_change_at = datetime.now() # Clear forced password change flag if set (SEC-12) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 7314287..06ebde6 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -294,16 +294,20 @@ async def reorder_tasks( if not project: raise HTTPException(status_code=404, detail="Project not found") - for item in items: - task_result = await db.execute( - select(ProjectTask).where( - ProjectTask.id == item.id, - ProjectTask.project_id == project_id - ) + # AC-4: Batch-fetch all tasks in one query instead of N sequential queries + task_ids = [item.id for item in items] + task_result = await db.execute( + select(ProjectTask).where( + ProjectTask.id.in_(task_ids), + ProjectTask.project_id == project_id, ) - task = task_result.scalar_one_or_none() - if task: - task.sort_order = item.sort_order + ) + tasks_by_id = {t.id: t for t in task_result.scalars().all()} + + order_map = {item.id: item.sort_order for item in items} + for task_id, task in tasks_by_id.items(): + if task_id in order_map: + task.sort_order = order_map[task_id] await db.commit() From 846019d5c191f1b9b36ab9bd0e30c6f8c691807c Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 13 Mar 2026 00:08:45 +0800 Subject: [PATCH 3/6] Phase 3: Backend queries and indexes optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AW-1: Add composite index on calendar_members(user_id, status) for the hot shared-calendar polling query - AS-6: Add composite index on ntfy_sent(user_id, sent_at) for dedup lookups - AW-5: Combine get_user_permission into single LEFT JOIN query instead of 2 sequential queries (called twice per event edit) - AC-5: Batch cascade_on_disconnect — single GROUP BY + bulk UPDATE instead of N per-calendar checks when a connection is severed - AW-6: Collapse admin dashboard 5 COUNT queries into single conditional aggregation using COUNT().filter() - AC-3: Cache get_current_settings in request.state to avoid redundant queries when multiple dependencies need settings in the same request Co-Authored-By: Claude Opus 4.6 --- .../versions/053_add_composite_indexes.py | 29 +++++++++ backend/app/routers/admin.py | 22 +++---- backend/app/routers/auth.py | 7 ++ backend/app/services/calendar_sharing.py | 64 +++++++++++-------- 4 files changed, 84 insertions(+), 38 deletions(-) create mode 100644 backend/alembic/versions/053_add_composite_indexes.py diff --git a/backend/alembic/versions/053_add_composite_indexes.py b/backend/alembic/versions/053_add_composite_indexes.py new file mode 100644 index 0000000..758d280 --- /dev/null +++ b/backend/alembic/versions/053_add_composite_indexes.py @@ -0,0 +1,29 @@ +"""Add composite indexes for calendar_members and ntfy_sent + +Revision ID: 053 +Revises: 052 +""" +from alembic import op + +revision = "053" +down_revision = "052" + + +def upgrade(): + # AW-1: Hot query polled every 5s uses (user_id, status) together + op.create_index( + "ix_calendar_members_user_id_status", + "calendar_members", + ["user_id", "status"], + ) + # AS-6: Dedup lookup in notification dispatch uses (user_id, sent_at) + op.create_index( + "ix_ntfy_sent_user_id_sent_at", + "ntfy_sent", + ["user_id", "sent_at"], + ) + + +def downgrade(): + op.drop_index("ix_ntfy_sent_user_id_sent_at", table_name="ntfy_sent") + op.drop_index("ix_calendar_members_user_id_status", table_name="calendar_members") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index ead0397..5026fc0 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -743,18 +743,18 @@ async def admin_dashboard( _actor: User = Depends(get_current_user), ): """Aggregate stats for the admin portal dashboard.""" - total_users = await db.scalar( - sa.select(sa.func.count()).select_from(User) - ) - active_users = await db.scalar( - sa.select(sa.func.count()).select_from(User).where(User.is_active == True) - ) - admin_count = await db.scalar( - sa.select(sa.func.count()).select_from(User).where(User.role == "admin") - ) - totp_count = await db.scalar( - sa.select(sa.func.count()).select_from(User).where(User.totp_enabled == True) + # AW-6: Single conditional aggregation instead of 5 separate COUNT queries + user_stats = await db.execute( + sa.select( + sa.func.count().label("total"), + sa.func.count().filter(User.is_active == True).label("active"), + sa.func.count().filter(User.role == "admin").label("admins"), + sa.func.count().filter(User.totp_enabled == True).label("totp"), + ).select_from(User) ) + row = user_stats.one() + total_users, active_users, admin_count, totp_count = row.tuple() + active_sessions = await db.scalar( sa.select(sa.func.count()).select_from(UserSession).where( UserSession.revoked == False, diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 71c7b51..1478b45 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -147,19 +147,26 @@ async def get_current_user( async def get_current_settings( + request: Request, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> Settings: """ Convenience dependency for routers that need Settings access. Always chain after get_current_user — never use standalone. + + AC-3: Cache in request.state so multiple dependencies don't re-query. """ + cached = getattr(request.state, "settings", None) + if cached is not None: + return cached result = await db.execute( select(Settings).where(Settings.user_id == current_user.id) ) settings_obj = result.scalar_one_or_none() if not settings_obj: raise HTTPException(status_code=500, detail="Settings not found for user") + request.state.settings = settings_obj return settings_obj diff --git a/backend/app/services/calendar_sharing.py b/backend/app/services/calendar_sharing.py index 71bba15..dc05c7f 100644 --- a/backend/app/services/calendar_sharing.py +++ b/backend/app/services/calendar_sharing.py @@ -7,7 +7,7 @@ import logging from datetime import datetime, timedelta from fastapi import HTTPException -from sqlalchemy import delete, select, text +from sqlalchemy import delete, select, text, update from sqlalchemy.ext.asyncio import AsyncSession from app.models.calendar import Calendar @@ -24,25 +24,29 @@ async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) """ Returns "owner" if the user owns the calendar, the permission string if they are an accepted member, or None if they have no access. - """ - cal = await db.execute( - select(Calendar).where(Calendar.id == calendar_id) - ) - calendar = cal.scalar_one_or_none() - if not calendar: - return None - if calendar.user_id == user_id: - return "owner" - member = await db.execute( - select(CalendarMember).where( - CalendarMember.calendar_id == calendar_id, - CalendarMember.user_id == user_id, - CalendarMember.status == "accepted", + AW-5: Single query with LEFT JOIN instead of 2 sequential queries. + """ + result = await db.execute( + select( + Calendar.user_id, + CalendarMember.permission, ) + .outerjoin( + CalendarMember, + (CalendarMember.calendar_id == Calendar.id) + & (CalendarMember.user_id == user_id) + & (CalendarMember.status == "accepted"), + ) + .where(Calendar.id == calendar_id) ) - row = member.scalar_one_or_none() - return row.permission if row else None + row = result.one_or_none() + if not row: + return None + owner_id, member_permission = row.tuple() + if owner_id == user_id: + return "owner" + return member_permission async def require_permission( @@ -202,16 +206,22 @@ async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int {"user_id": user_a_id, "cal_ids": b_cal_ids}, ) - # Reset is_shared on calendars with no remaining members + # AC-5: Single aggregation query instead of N per-calendar checks all_cal_ids = a_cal_ids + b_cal_ids - for cal_id in all_cal_ids: - remaining = await db.execute( - select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1) + if all_cal_ids: + # Find which calendars still have members + has_members_result = await db.execute( + select(CalendarMember.calendar_id) + .where(CalendarMember.calendar_id.in_(all_cal_ids)) + .group_by(CalendarMember.calendar_id) ) - if not remaining.scalar_one_or_none(): - cal_result = await db.execute( - select(Calendar).where(Calendar.id == cal_id) + cals_with_members = {row[0] for row in has_members_result.all()} + + # Reset is_shared on calendars with no remaining members + empty_cal_ids = [cid for cid in all_cal_ids if cid not in cals_with_members] + if empty_cal_ids: + await db.execute( + update(Calendar) + .where(Calendar.id.in_(empty_cal_ids)) + .values(is_shared=False) ) - cal = cal_result.scalar_one_or_none() - if cal: - cal.is_shared = False From 2ab7121e42b8fd57d9303324015406f276434912 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 13 Mar 2026 00:12:33 +0800 Subject: [PATCH 4/6] Phase 4: Frontend performance optimizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AW-2: Scope calendar events fetch to visible date range via start/end query params, leveraging existing backend support - AW-3: Reduce calendar events poll from 5s to 30s (personal organiser doesn't need 12 API calls/min) - AS-4: Gate shared-calendar polling on hasSharedCalendars — saves 12 wasted API calls/min for personal-only users - AS-2: Lazy-load all route components with React.lazy() — only AdminPortal was previously lazy, now all 10 routes are code-split - AS-1: Add Vite manualChunks to split FullCalendar (~400KB), React, TanStack Query, and UI libs into separate cacheable chunks - AS-3: Extract clockNow into isolated ClockDisplay memo component — prevents all 8 dashboard widgets from re-rendering every minute Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.tsx | 47 +++--- .../src/components/calendar/CalendarPage.tsx | 21 ++- .../components/dashboard/DashboardPage.tsx | 135 ++++++++++-------- frontend/src/hooks/useCalendars.ts | 14 +- frontend/vite.config.ts | 19 +++ 5 files changed, 147 insertions(+), 89 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1bb5810..17cc135 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,19 +3,24 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuth } from '@/hooks/useAuth'; import LockScreen from '@/components/auth/LockScreen'; import AppLayout from '@/components/layout/AppLayout'; -import DashboardPage from '@/components/dashboard/DashboardPage'; -import TodosPage from '@/components/todos/TodosPage'; -import CalendarPage from '@/components/calendar/CalendarPage'; -import RemindersPage from '@/components/reminders/RemindersPage'; -import ProjectsPage from '@/components/projects/ProjectsPage'; -import ProjectDetail from '@/components/projects/ProjectDetail'; -import PeoplePage from '@/components/people/PeoplePage'; -import LocationsPage from '@/components/locations/LocationsPage'; -import SettingsPage from '@/components/settings/SettingsPage'; -import NotificationsPage from '@/components/notifications/NotificationsPage'; +// AS-2: Lazy-load all route components to reduce initial bundle parse time +const DashboardPage = lazy(() => import('@/components/dashboard/DashboardPage')); +const TodosPage = lazy(() => import('@/components/todos/TodosPage')); +const CalendarPage = lazy(() => import('@/components/calendar/CalendarPage')); +const RemindersPage = lazy(() => import('@/components/reminders/RemindersPage')); +const ProjectsPage = lazy(() => import('@/components/projects/ProjectsPage')); +const ProjectDetail = lazy(() => import('@/components/projects/ProjectDetail')); +const PeoplePage = lazy(() => import('@/components/people/PeoplePage')); +const LocationsPage = lazy(() => import('@/components/locations/LocationsPage')); +const SettingsPage = lazy(() => import('@/components/settings/SettingsPage')); +const NotificationsPage = lazy(() => import('@/components/notifications/NotificationsPage')); const AdminPortal = lazy(() => import('@/components/admin/AdminPortal')); +const RouteFallback = () => ( +
Loading...
+); + function ProtectedRoute({ children }: { children: React.ReactNode }) { const { authStatus, isLoading } = useAuth(); @@ -57,21 +62,21 @@ function App() { } > } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> + }>} /> - Loading...}> + }> diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 6dd9449..c1754ba 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -205,13 +205,22 @@ export default function CalendarPage() { return () => el.removeEventListener('wheel', handleWheel); }, []); + // AW-2: Track visible date range for scoped event fetching + const [visibleRange, setVisibleRange] = useState<{ start: string; end: string } | null>(null); + const { data: events = [] } = useQuery({ - queryKey: ['calendar-events'], + queryKey: ['calendar-events', visibleRange?.start, visibleRange?.end], queryFn: async () => { - const { data } = await api.get('/events'); + const params: Record = {}; + if (visibleRange) { + params.start = visibleRange.start; + params.end = visibleRange.end; + } + const { data } = await api.get('/events', { params }); return data; }, - refetchInterval: 5_000, + // AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min + refetchInterval: 30_000, }); const selectedEvent = useMemo( @@ -467,6 +476,12 @@ export default function CalendarPage() { const handleDatesSet = (arg: DatesSetArg) => { setCalendarTitle(arg.view.title); setCurrentView(arg.view.type as CalendarView); + // AW-2: Capture visible range for scoped event fetching + const start = arg.start.toISOString().split('T')[0]; + const end = arg.end.toISOString().split('T')[0]; + setVisibleRange((prev) => + prev?.start === start && prev?.end === end ? prev : { start, end } + ); }; const navigatePrev = () => calendarRef.current?.getApi().prev(); diff --git a/frontend/src/components/dashboard/DashboardPage.tsx b/frontend/src/components/dashboard/DashboardPage.tsx index 7aa0f44..f9caeb1 100644 --- a/frontend/src/components/dashboard/DashboardPage.tsx +++ b/frontend/src/components/dashboard/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, memo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { format } from 'date-fns'; @@ -34,6 +34,75 @@ function getGreeting(name?: string): string { return `Good night${suffix}`; } +// AS-3: Isolated clock component — only this re-renders every minute, +// not all 8 dashboard widgets. +const ClockDisplay = memo(function ClockDisplay({ dataUpdatedAt, onRefresh }: { + dataUpdatedAt?: number; + onRefresh: () => void; +}) { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + let intervalId: ReturnType; + let timeoutId: ReturnType; + + function startClock() { + clearTimeout(timeoutId); + clearInterval(intervalId); + setNow(new Date()); + const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds(); + timeoutId = setTimeout(() => { + setNow(new Date()); + intervalId = setInterval(() => setNow(new Date()), 60_000); + }, msUntilNextMinute); + } + + startClock(); + function handleVisibility() { + if (document.visibilityState === 'visible') startClock(); + } + document.addEventListener('visibilitychange', handleVisibility); + return () => { + clearTimeout(timeoutId); + clearInterval(intervalId); + document.removeEventListener('visibilitychange', handleVisibility); + }; + }, []); + + const updatedAgo = dataUpdatedAt + ? (() => { + const mins = Math.floor((now.getTime() - dataUpdatedAt) / 60_000); + if (mins < 1) return 'just now'; + if (mins === 1) return '1 min ago'; + return `${mins} min ago`; + })() + : null; + + return ( +
+

+ {format(now, 'h:mm a')} + | + {format(now, 'EEEE, MMMM d, yyyy')} +

+ {updatedAgo && ( + <> + · + Updated {updatedAgo} + + + )} +
+ ); +}); + export default function DashboardPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -42,38 +111,7 @@ export default function DashboardPage() { const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null); const [dropdownOpen, setDropdownOpen] = useState(false); const dropdownRef = useRef(null); - const [clockNow, setClockNow] = useState(() => new Date()); - - // Live clock — synced to the minute boundary, re-syncs after tab sleep/resume - useEffect(() => { - let intervalId: ReturnType; - let timeoutId: ReturnType; - - function startClock() { - clearTimeout(timeoutId); - clearInterval(intervalId); - setClockNow(new Date()); - const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds(); - timeoutId = setTimeout(() => { - setClockNow(new Date()); - intervalId = setInterval(() => setClockNow(new Date()), 60_000); - }, msUntilNextMinute); - } - - startClock(); - - // Re-sync when tab becomes visible again (after sleep/background throttle) - function handleVisibility() { - if (document.visibilityState === 'visible') startClock(); - } - document.addEventListener('visibilitychange', handleVisibility); - - return () => { - clearTimeout(timeoutId); - clearInterval(intervalId); - document.removeEventListener('visibilitychange', handleVisibility); - }; - }, []); + // Clock state moved to (AS-3) // Click outside to close dropdown useEffect(() => { @@ -191,15 +229,6 @@ export default function DashboardPage() { ); } - const updatedAgo = dataUpdatedAt - ? (() => { - const mins = Math.floor((clockNow.getTime() - dataUpdatedAt) / 60_000); - if (mins < 1) return 'just now'; - if (mins === 1) return '1 min ago'; - return `${mins} min ago`; - })() - : null; - return (
{/* Header — greeting + date + quick add */} @@ -208,27 +237,7 @@ export default function DashboardPage() {

{getGreeting(settings?.preferred_name || undefined)}

-
-

- {format(clockNow, 'h:mm a')} - | - {format(clockNow, 'EEEE, MMMM d, yyyy')} -

- {updatedAgo && ( - <> - · - Updated {updatedAgo} - - - )} -
+