Files
babymonitor/backend_improvements.md
T

321 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Backend Improvement Report
## 1. No Authentication — Security
**Severity: Critical**
There is zero authentication on any endpoint. Any caller with network access can create, read, update, and delete all sleep session data. CORS configuration in `app/main.py:18-24` restricts browser origins but does nothing to protect against direct HTTP clients (curl, scripts, etc.).
**Recommended fix (incremental):**
1. **Immediate:** Add a single shared API key checked via an `Authorization: Bearer <key>` header using a FastAPI dependency. The key is stored in an environment variable.
2. **Longer term:** Proper user authentication if the app ever becomes multi-user.
Example dependency:
```python
from fastapi import Security, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
bearer = HTTPBearer()
def verify_api_key(creds: HTTPAuthorizationCredentials = Security(bearer)):
if creds.credentials != settings.API_KEY:
raise HTTPException(status_code=401, detail="Unauthorized")
```
---
## 2. O(days × slots × sessions) Timetable Query — Performance
**Severity: Critical**
`app/routers/stats.py:77-99` contains a triple-nested Python loop to build the timetable:
```python
while current_date <= to_date.date():
for i in range(96): # 96 fifteen-minute slots per day
for s in sessions: # ALL sessions loaded into memory
if s.start_time <= slot_end and (s.end_time is None or s.end_time >= slot_start):
is_sleeping = True
break
```
With 30 days and 1,000 sessions this is ~2.88 million comparisons executed in Python. The comparison work should happen in the database.
**Fix:** For each day, run a single SQL query that returns the sessions overlapping that day's range, then build the 96 slots against only the relevant sessions. Alternatively, generate a CTE of slot boundaries and join against `sleep_session` in a single query. Even the naive "one query per day" approach is far faster than the current Python loop because the database uses the index (see item 4).
---
## 3. In-Python Aggregation in Other Stats Endpoints — Performance
**Severity: High**
The same load-everything-then-loop pattern appears in the other two stats endpoints:
- `/api/stats/daily-summary` (`stats.py:28-47`): loads all sessions for the date range, then loops through each one in Python to compute per-day totals.
- `/api/stats/wake-sleep-times` (`stats.py:119-134`): loads all sessions, then loops per day to find the first wakeup and last bedtime.
Both of these can be replaced with a single SQL GROUP BY query per endpoint:
- `daily-summary`: `SELECT DATE(start_time), SUM(...) FROM sleep_session WHERE ... GROUP BY DATE(start_time)`
- `wake-sleep-times`: `SELECT DATE(start_time), MIN(start_time), MAX(end_time) FROM sleep_session WHERE ... GROUP BY DATE(start_time)`
This removes the O(sessions × days) Python loop entirely.
---
## 4. Missing Database Indexes — Performance
**Severity: High**
Every stats query and the session list query filter and sort on `start_time` and `end_time`, but neither column has an index in `app/models.py`.
**Fix:** Add indexes to the model:
```python
from sqlmodel import Field, SQLModel, Index
class SleepSession(SQLModel, table=True):
...
# after the class definition:
Index("ix_sleep_session_start_time", SleepSession.__table__.c.start_time)
Index("ix_sleep_session_end_time", SleepSession.__table__.c.end_time)
```
Or declare them inline with `sa_column`:
```python
start_time: datetime = Field(sa_column=Column(DateTime, index=True))
```
This is especially important for the timetable query once it is pushed into SQL.
---
## 5. Magic Return Types on Session End — Code Quality / Simplification
**Severity: High**
`app/crud.py:57-82` returns `Union[SleepSession, bool, str, None]` where the string value is the magic sentinel `"invalid"`:
```python
# crud.py
if session is None:
return None
if session.end_time is not None:
return False
if ...:
return "invalid"
return session
```
The caller in `app/routers/sessions.py:64-73` then has to chain `if result is None`, `if result is False`, `if result == "invalid"` checks. This is fragile, untestable without string matching, and breaks static analysis.
**Fix:** Define typed exceptions and raise them:
```python
class SessionNotFoundError(Exception): pass
class SessionAlreadyEndedError(Exception): pass
class InvalidEndTimeError(Exception): pass
```
`crud.py` raises, `sessions.py` catches and maps to the appropriate `HTTPException`. The function return type becomes simply `SleepSession`.
---
## 6. Silent Error Suppression in Stats — Code Quality / Documentation
**Severity: Medium**
`app/routers/stats.py:24-25` and `114-115` catch invalid query parameter values and silently return empty data:
```python
try:
day_start_time = time.fromisoformat(day_start)
except ValueError:
return {"days": []} # client gets empty data with no explanation
```
The client has no way to distinguish "no data for this range" from "your parameters were malformed". This makes debugging difficult.
**Fix:** Return a `422 Unprocessable Entity` with a descriptive message:
```python
from fastapi import HTTPException
try:
day_start_time = time.fromisoformat(day_start)
except ValueError:
raise HTTPException(status_code=422, detail=f"Invalid day_start format: {day_start!r}")
```
Alternatively, use FastAPI's `Query()` with a regex validator so malformed values are rejected before the handler runs.
---
## 7. `updated_at` Field Never Updated — Bug
**Severity: Medium**
`app/models.py` declares an `updated_at` column, but `app/crud.py:106-113` (the update handler) never sets it. After any PATCH, `updated_at` retains the creation timestamp, making it misleading.
**Fix:** In the update function, add:
```python
session.updated_at = datetime.utcnow()
```
---
## 8. Dead Code — Code Slop
**Severity: Medium**
Three functions are defined but never called anywhere in the application:
| Function | File | Lines |
|----------|------|-------|
| `ensure_aware()` | `app/crud.py` | 7-10 |
| `minutes_after()` | `app/utils/time_utils.py` | 60-64 |
Additionally, `app/utils/time_utils.py` exports `day_start_pattern()` which is only used internally within `get_day_night_splits()` — it does not need to be a module-level export.
**Fix:** Delete `ensure_aware()` and `minutes_after()`. Make `day_start_pattern()` a local helper (leading underscore or inline).
---
## 9. Duplicate Test File — Code Slop
**Severity: Medium**
`tests/test_sessions_fixed.py` is an exact copy of `tests/test_sessions.py`. It appears to be a temporary file that was never cleaned up.
**Fix:** Delete `tests/test_sessions_fixed.py`.
---
## 10. No Application Logging — Observability
**Severity: Medium**
There are zero log statements anywhere in the backend. No requests, errors, slow queries, or business events are recorded. This makes diagnosing production issues entirely dependent on guesswork.
**Fix:** Add structured logging using Python's `logging` module (or `structlog` for structured output). At minimum log:
- Every request with method, path, and response status (can use a FastAPI middleware or Starlette's built-in `ServerErrorMiddleware`)
- Unhandled exceptions with stack traces
- Slow database queries (via SQLAlchemy event listeners if needed)
- Business events: session started, session ended, session deleted
Example setup in `main.py`:
```python
import logging
logging.basicConfig(level=settings.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s %(message)s")
logger = logging.getLogger(__name__)
```
Note: `app/config.py` already has a `LOG_LEVEL` setting — it is currently configured but never consumed.
---
## 11. No Rate Limiting — Security
**Severity: Medium**
There is no rate limiting on any endpoint. A client could spam POST requests to `/api/sessions` flooding the database, or hammer the expensive timetable endpoint.
**Fix:** Add `slowapi` (a FastAPI-compatible rate limiter):
```python
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
@router.post("/sessions")
@limiter.limit("10/minute")
async def create_session(...): ...
```
A reasonable starting limit: 10 writes/minute per IP, 60 reads/minute per IP.
---
## 12. Missing Test Coverage — Testing
**Severity: High**
Current test coverage has significant gaps:
| Area | Current state |
|------|--------------|
| `/api/stats/timetable` | 1 basic happy-path test |
| `/api/stats/daily-summary` | 1 basic happy-path test |
| `/api/stats/wake-sleep-times` | No tests |
| Date boundary conditions | No tests |
| Concurrent session creation | No tests |
| Invalid query parameters | No tests |
**Recommended additions:**
- Parametrised tests for all stats endpoints covering: empty date ranges, single session spanning midnight, session starting before range and ending inside it, multiple sessions on the same day.
- Test that invalid `day_start`/`day_end` values return 422 (once fix #6 is applied).
- Test that `limit` parameter is bounded (once fix #14 is applied).
- Regression test asserting `updated_at` changes after a PUT request (covers fix #7).
---
## 13. Unused Dependency — Code Slop
**Severity: Low**
`python-multipart` is listed in `pyproject.toml:13` but is not used by this application (FastAPI only requires it if you use `Form` parameters, which this API does not).
**Fix:** Remove the dependency:
```
uv remove python-multipart
```
---
## 14. Unbounded `limit` Parameter — Security / Reliability
**Severity: Medium**
`app/routers/sessions.py` accepts a `limit` query parameter with default 200 and no upper bound. A caller can pass `limit=10000000` and force a full table scan returning all rows.
**Fix:** Use FastAPI's `Query` with a `le` constraint:
```python
from fastapi import Query
async def get_sessions(limit: int = Query(default=200, ge=1, le=1000)):
```
---
## 15. Hardcoded CORS Origin — Configuration
**Severity: Low**
`app/config.py:7` hardcodes the CORS allowed origin:
```python
CORS_ORIGINS: str = "http://localhost:5173"
```
This is already an env-var-overridable `Settings` field (via Pydantic's `BaseSettings`), so the mechanism is correct. But the default value means local development works without configuration while production requires the env var to be set — if it is forgotten, the production frontend gets CORS errors.
**Documentation fix:** Add a comment or a startup check that warns if the running environment looks like production but `CORS_ORIGINS` still has the localhost default.
---
## Summary Table
| # | Category | File(s) | Severity |
|---|----------|---------|----------|
| 1 | Security — no auth | All routers | Critical |
| 2 | Performance — O(n³) loop | `stats.py:77-99` | Critical |
| 3 | Performance — in-Python aggregation | `stats.py:28-47`, `119-134` | High |
| 4 | Performance — missing indexes | `models.py` | High |
| 5 | Code quality — magic return types | `crud.py:57-82`, `sessions.py:64-73` | High |
| 6 | Code quality — silent error suppression | `stats.py:24-25`, `114-115` | Medium |
| 7 | Bug — `updated_at` not updated | `crud.py:106-113` | Medium |
| 8 | Code slop — dead functions | `crud.py:7-10`, `time_utils.py:60-64` | Medium |
| 9 | Code slop — duplicate test file | `tests/test_sessions_fixed.py` | Medium |
| 10 | Observability — no logging | All files | Medium |
| 11 | Security — no rate limiting | All routers | Medium |
| 12 | Testing — missing coverage | `tests/` | High |
| 13 | Code slop — unused dependency | `pyproject.toml:13` | Low |
| 14 | Security — unbounded limit param | `sessions.py` | Medium |
| 15 | Configuration — hardcoded CORS | `config.py:7` | Low |