API — habits router¶
backend/src/routers/habits.py (450 lines).
APIRouter(prefix="/habits", tags=["habits"]) (habits.py:36).
Constants: _MAX_HABITS_PER_USER = 100, _COMPLETIONS_WINDOW_DAYS = 90
(the embedded-completions window) (habits.py:39,47).
| Method | Path | Auth | Request DTO | Response | Success | Errors |
|---|---|---|---|---|---|---|
| POST | /habits/ |
JWT | HabitCreate |
HabitWithGoals |
200 | 409 habit_quota_exceeded (habits.py:164), 409 duplicate_habit_name (pre-check and DB-race path, habits.py:177,198) |
| GET | /habits/ |
JWT | PaginationParams |
Page[HabitWithGoals] or bare list |
200 | — |
| GET | /habits/{habit_id} |
JWT + owned | — | HabitWithGoals |
200 | 404 / 403 |
| PUT | /habits/{habit_id} |
JWT + owned | HabitCreate |
Habit schema |
200 | 404 / 403; 409 duplicate_habit_name on rename collision (habits.py:298) |
| DELETE | /habits/{habit_id} |
JWT + owned | — | — | 204 | 404 / 403 |
| DELETE | /habits/{habit_id}/completions |
JWT + owned | — | — | 204 | 404 / 403 |
| PUT | /habits/{habit_id}/goals/units |
JWT + owned | GoalUnitsUpdate |
list[Goal] |
200 | 404 / 403 |
| GET | /habits/{habit_id}/stats |
JWT | — | HabitStats |
200 | 404 habit_not_found / 403 forbidden (habits.py:399-402) |
Notes:
- Create provisions a habit plus three default tier goals
(low/clear/stretch), enforcing the 100-habit quota and per-user
case-insensitive name uniqueness — the latter is guaranteed at the DB
level by migration
b5c6d7e8f9a0_habit_unique_user_lower_name, so the race path also maps to 409 (habits.py:164-228). - Listing sorts by
sort_orderand eager-loads goals with a bounded 90-day completions window;?paginate=trueopts into the envelope (habits.py:242-266). - Ownership is
require_owned_habit's canonical 404 (missing) / 403 (cross-user, audited) split (backend/src/dependencies/ownership.py:75-87). - Clear completions bulk-deletes every completion for the habit's
goals in one statement "so a start-date reset … leaves no stale rows
behind", with a defense-in-depth
user_idfilter (habits.py:324-338). - Goal-units update (issue #289) replaces the client's per-tier
fan-out with one transaction: "either every goal moves to the new unit
fields or none do. Tier identity and per-tier targets are deliberately
untouched" (
habits.py:406-421). - Stats delegates to
domain.habit_stats.compute_habit_statsin the caller's timezone, choosing the subtractive path per goal type — see domain/habit-stats and domain/streaks (habits.py:439-450).
DTOs: backend/src/schemas/habit.py, schemas/habit_stats.py. Model:
data-model/habits-goals.
Grounded in adepthood@fbc529d, 2026-07-31.