Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1585 lines
70 KiB
Markdown
1585 lines
70 KiB
Markdown
# LifeTrack — Architecture technique (v1)
|
||
|
||
> Document de conception destiné aux agents d'implémentation. Il est **exhaustif et normatif** :
|
||
> toute décision non couverte ici doit respecter la section 10 (CONVENTIONS), qui sera extraite
|
||
> telle quelle dans `CONVENTIONS.md`.
|
||
>
|
||
> Langue : prose et UI en **français**, identifiants de code, noms de fichiers, routes API et
|
||
> commentaires en **anglais**.
|
||
|
||
---
|
||
|
||
## 1. Vue d'ensemble
|
||
|
||
LifeTrack est une application web auto-hébergée de suivi de vie personnelle, déployée à la maison
|
||
via Docker Compose (Windows ou Linux). Un seul utilisateur principal, mais le schéma de données est
|
||
multi-utilisateur dès la v1 (toutes les tables métier portent `user_id`).
|
||
|
||
**Stack imposée (non négociable)** :
|
||
|
||
| Couche | Technologie |
|
||
|---------------|-------------|
|
||
| API | Python 3.12, FastAPI, SQLAlchemy 2.0 (typé), Pydantic v2 |
|
||
| Base | PostgreSQL 16 (driver `psycopg` v3) |
|
||
| Frontend | React 18, TypeScript, Vite, TailwindCSS, Apache ECharts, TanStack React Query v5, React Router v6 |
|
||
| Auth | JWT (access token) + clés API d'appareil ; hachage argon2 (`pwdlib`) |
|
||
| Déploiement | Docker Compose : `postgres` + `api` (uvicorn) + `web` (nginx, sert le build Vite et proxifie `/api`) |
|
||
|
||
**Modules métier v1** : `health` (santé/fitness — module principal), `vape` (sevrage tabagique),
|
||
`finance` (finances personnelles), plus les modules transverses `auth` et `imports`.
|
||
|
||
**Principes structurants** :
|
||
|
||
1. **Modularité par auto-découverte** — backend : chaque module sous `app/modules/<name>/` est
|
||
découvert par itération `pkgutil` ; frontend : chaque module sous `src/modules/<name>/` est
|
||
découvert par `import.meta.glob`. **Aucun développeur de module ne modifie jamais un fichier
|
||
partagé** (`main.py`, `router.tsx`, `Sidebar.tsx`…) : il suffit de créer le dossier du module
|
||
en respectant le contrat.
|
||
2. **Framework de connecteurs/importeurs** — les sources de données (CSV Foodvisor, OFX bancaire,
|
||
exports PayPal, poussées JSON depuis un pont Android Health Connect…) sont branchées via un
|
||
registre d'importeurs (`BaseImporter`) et de handlers d'ingestion (`BaseIngestHandler`).
|
||
Ajouter une source = ajouter une classe, rien d'autre.
|
||
3. **Temps** — stockage en **UTC** (colonnes `TIMESTAMPTZ`), affichage et agrégations journalières
|
||
dans le fuseau `Europe/Paris` (paramétrable via `?tz=`). Les totaux « par jour » fournis par les
|
||
sources (pas de timestamp) sont stockés dans des colonnes `DATE` représentant le **jour local**.
|
||
4. **v1 pragmatique** — création des tables via `Base.metadata.create_all()` au démarrage
|
||
(Alembic sera introduit plus tard) ; imports de fichiers traités **de façon synchrone** dans la
|
||
requête HTTP (volumes personnels faibles) ; pas de refresh token (access token de 7 jours).
|
||
|
||
---
|
||
|
||
## 2. Arborescence du monorepo
|
||
|
||
```text
|
||
LifeTrack/
|
||
├── README.md
|
||
├── CONVENTIONS.md # extrait verbatim de la section 10 de ce document
|
||
├── .gitignore
|
||
├── .env.example # variables d'environnement (voir §9.5)
|
||
├── docker-compose.yml # production (postgres + api + web)
|
||
├── docker-compose.dev.yml # surcouche développement (hot reload)
|
||
├── docker/
|
||
│ ├── api.Dockerfile # build multi-étapes Python
|
||
│ ├── web.Dockerfile # build Vite -> nginx
|
||
│ └── nginx.conf # sert le SPA + proxy /api -> api:8000
|
||
├── docs/
|
||
│ └── design/
|
||
│ └── architecture.md # ce document
|
||
├── apps/
|
||
│ ├── api/
|
||
│ │ ├── requirements.txt
|
||
│ │ ├── requirements-dev.txt
|
||
│ │ ├── pytest.ini
|
||
│ │ └── app/
|
||
│ │ ├── __init__.py
|
||
│ │ ├── main.py # fabrique d'application + lifespan
|
||
│ │ ├── core/
|
||
│ │ │ ├── __init__.py
|
||
│ │ │ ├── config.py # Settings (pydantic-settings)
|
||
│ │ │ ├── database.py # engine, SessionLocal, Base, get_db
|
||
│ │ │ ├── mixins.py # TimestampMixin, SourceMixin (dédup)
|
||
│ │ │ ├── security.py # hash mots de passe, JWT, clés API
|
||
│ │ │ ├── dependencies.py # get_current_user, require_ingest_scope…
|
||
│ │ │ ├── errors.py # AppError + handlers (forme d'erreur)
|
||
│ │ │ ├── pagination.py # PageParams, Page[T], paginate()
|
||
│ │ │ ├── timeutils.py # utcnow(), local_day(), parse_tz()
|
||
│ │ │ ├── module_loader.py # AUTO-DÉCOUVERTE pkgutil (routers, models…)
|
||
│ │ │ ├── importing/
|
||
│ │ │ │ ├── __init__.py
|
||
│ │ │ │ ├── base.py # BaseImporter, NormalizedRecord, UpsertOutcome
|
||
│ │ │ │ ├── registry.py # IMPORTER_REGISTRY, @register_importer
|
||
│ │ │ │ └── hashing.py # content_hash() canonique
|
||
│ │ │ └── ingest/
|
||
│ │ │ ├── __init__.py
|
||
│ │ │ ├── base.py # BaseIngestHandler, IngestRecord
|
||
│ │ │ └── registry.py # INGEST_REGISTRY, @register_ingest_handler
|
||
│ │ ├── modules/
|
||
│ │ │ ├── __init__.py
|
||
│ │ │ ├── auth/
|
||
│ │ │ │ ├── __init__.py
|
||
│ │ │ │ ├── router.py # /api/auth/* (setup, login, me, device-keys)
|
||
│ │ │ │ ├── models.py # User, DeviceApiKey
|
||
│ │ │ │ ├── schemas.py
|
||
│ │ │ │ └── service.py
|
||
│ │ │ ├── health/
|
||
│ │ │ │ ├── __init__.py
|
||
│ │ │ │ ├── router.py # /api/health/*
|
||
│ │ │ │ ├── models.py # HealthProfile, WeightEntry, BodyMeasurement,
|
||
│ │ │ │ │ # DailyActivity, Workout, FoodEntry
|
||
│ │ │ │ ├── schemas.py
|
||
│ │ │ │ ├── service.py
|
||
│ │ │ │ ├── calculations.py # BMR/TDEE, balance énergétique, projections
|
||
│ │ │ │ ├── importers.py # FoodvisorCsvImporter, HealthConnectCsv…
|
||
│ │ │ │ └── ingest.py # HealthIngestHandler (pont Android)
|
||
│ │ │ ├── vape/
|
||
│ │ │ │ ├── __init__.py
|
||
│ │ │ │ ├── router.py # /api/vape/*
|
||
│ │ │ │ ├── models.py # VapeCostModel, VapeDailyLog, CoilChange,
|
||
│ │ │ │ │ # SmokingBaseline
|
||
│ │ │ │ ├── schemas.py
|
||
│ │ │ │ ├── service.py
|
||
│ │ │ │ ├── calculations.py # coût/ml, coût/jour, économies cumulées
|
||
│ │ │ │ └── ingest.py # VapeIngestHandler (optionnel)
|
||
│ │ │ ├── finance/
|
||
│ │ │ │ ├── __init__.py
|
||
│ │ │ │ ├── router.py # /api/finance/*
|
||
│ │ │ │ ├── models.py # Account, Transaction, Category,
|
||
│ │ │ │ │ # CategoryRule, Budget
|
||
│ │ │ │ ├── schemas.py
|
||
│ │ │ │ ├── service.py
|
||
│ │ │ │ ├── categorize.py # moteur de règles + détection récurrences
|
||
│ │ │ │ └── importers.py # BankGenericCsv, OfxImporter, PaypalCsv
|
||
│ │ │ └── imports/
|
||
│ │ │ ├── __init__.py
|
||
│ │ │ ├── router.py # /api/imports/* ET /api/ingest/{domain}
|
||
│ │ │ ├── models.py # ImportRun
|
||
│ │ │ ├── schemas.py
|
||
│ │ │ └── service.py # orchestration d'un import (run_import)
|
||
│ │ └── tests/
|
||
│ │ ├── conftest.py # app de test + SQLite/postgres éphémère
|
||
│ │ ├── test_auth.py
|
||
│ │ ├── test_module_loader.py
|
||
│ │ ├── test_imports.py
|
||
│ │ └── modules/… # tests par module
|
||
│ └── web/
|
||
│ ├── index.html
|
||
│ ├── package.json
|
||
│ ├── tsconfig.json
|
||
│ ├── vite.config.ts
|
||
│ ├── tailwind.config.ts
|
||
│ ├── postcss.config.js
|
||
│ └── src/
|
||
│ ├── main.tsx # bootstrap React + QueryClientProvider
|
||
│ ├── styles/
|
||
│ │ └── index.css # @tailwind + variables de thème sombre
|
||
│ ├── types/
|
||
│ │ ├── module.ts # ModuleManifest, NavItem (contrat frontend)
|
||
│ │ └── api.ts # Page<T>, ApiErrorShape
|
||
│ ├── lib/
|
||
│ │ ├── api.ts # wrapper fetch (baseURL /api, JWT, erreurs)
|
||
│ │ ├── queryClient.ts # instance TanStack Query
|
||
│ │ ├── dates.ts # helpers ISO <-> affichage fr-FR
|
||
│ │ └── format.ts # nombres, €, kg, kcal en fr-FR
|
||
│ ├── components/
|
||
│ │ ├── ui/ # Button, Card, Input, Select, Modal, Table,
|
||
│ │ │ │ # Badge, Spinner, EmptyState, PageHeader,
|
||
│ │ │ └── … # StatCard, Tabs, DateRangePicker
|
||
│ │ └── charts/
|
||
│ │ ├── EChart.tsx # wrapper ECharts (resize, dispose, thème)
|
||
│ │ └── theme.ts # thème sombre ECharts partagé
|
||
│ ├── app/
|
||
│ │ ├── App.tsx
|
||
│ │ ├── router.tsx # assemble layout + routes des modules
|
||
│ │ ├── modules.ts # AUTO-DÉCOUVERTE import.meta.glob
|
||
│ │ ├── layout/
|
||
│ │ │ ├── AppLayout.tsx # sidebar + topbar + <Outlet/>
|
||
│ │ │ ├── Sidebar.tsx # nav générée depuis les manifestes
|
||
│ │ │ └── Topbar.tsx
|
||
│ │ ├── auth/
|
||
│ │ │ ├── AuthContext.tsx # token, user, login/logout
|
||
│ │ │ └── ProtectedRoute.tsx # redirige /login ou /setup
|
||
│ │ └── pages/
|
||
│ │ ├── LoginPage.tsx # hors modules (coquille applicative)
|
||
│ │ ├── SetupPage.tsx # assistant premier démarrage
|
||
│ │ └── NotFoundPage.tsx
|
||
│ └── modules/
|
||
│ ├── home/
|
||
│ │ ├── index.ts # manifeste (ordre 0, path "/")
|
||
│ │ ├── strings.ts
|
||
│ │ ├── api.ts
|
||
│ │ └── pages/HomePage.tsx # tableau de bord global (widgets)
|
||
│ ├── health/
|
||
│ │ ├── index.ts
|
||
│ │ ├── strings.ts
|
||
│ │ ├── api.ts # hooks React Query du module
|
||
│ │ ├── pages/
|
||
│ │ │ ├── HealthDashboardPage.tsx
|
||
│ │ │ ├── WeightPage.tsx
|
||
│ │ │ ├── MeasurementsPage.tsx
|
||
│ │ │ ├── ActivityPage.tsx
|
||
│ │ │ ├── WorkoutsPage.tsx
|
||
│ │ │ └── NutritionPage.tsx
|
||
│ │ └── components/ # widgets propres au module
|
||
│ ├── vape/
|
||
│ │ ├── index.ts, strings.ts, api.ts
|
||
│ │ └── pages/ (VapeDashboardPage, VapeLogPage, CoilsPage,
|
||
│ │ CostModelPage, SavingsPage)
|
||
│ ├── finance/
|
||
│ │ ├── index.ts, strings.ts, api.ts
|
||
│ │ └── pages/ (FinanceDashboardPage, TransactionsPage,
|
||
│ │ CategoriesPage, BudgetsPage, RecurringPage)
|
||
│ ├── imports/
|
||
│ │ ├── index.ts, strings.ts, api.ts
|
||
│ │ └── pages/ImportsPage.tsx # upload + historique des ImportRun
|
||
│ └── settings/
|
||
│ ├── index.ts, strings.ts, api.ts
|
||
│ └── pages/ (ProfilePage, DeviceKeysPage)
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Backend — structure FastAPI
|
||
|
||
### 3.1 Fabrique d'application et cycle de vie
|
||
|
||
`apps/api/app/main.py` :
|
||
|
||
```python
|
||
from contextlib import asynccontextmanager
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
|
||
from app.core.config import get_settings
|
||
from app.core.database import Base, engine
|
||
from app.core.errors import register_error_handlers
|
||
from app.core.module_loader import import_side_modules, register_routers
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
# Import every module's models/importers/ingest so that:
|
||
# - Base.metadata knows all tables before create_all
|
||
# - importer & ingest registries are populated (decorator side effects)
|
||
import_side_modules()
|
||
# v1 table-creation strategy: create_all on startup (Alembic later).
|
||
Base.metadata.create_all(bind=engine)
|
||
yield
|
||
|
||
|
||
def create_app() -> FastAPI:
|
||
settings = get_settings()
|
||
app = FastAPI(
|
||
title="LifeTrack API",
|
||
version="1.0.0",
|
||
docs_url="/api/docs",
|
||
redoc_url=None,
|
||
openapi_url="/api/openapi.json",
|
||
lifespan=lifespan,
|
||
)
|
||
if settings.cors_origins:
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=settings.cors_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
register_error_handlers(app)
|
||
register_routers(app) # auto-discovery, see §3.4
|
||
|
||
@app.get("/api/healthz", include_in_schema=False)
|
||
def healthz() -> dict[str, str]:
|
||
return {"status": "ok"}
|
||
|
||
return app
|
||
|
||
|
||
app = create_app()
|
||
```
|
||
|
||
Lancement dev : `uvicorn app.main:app --reload --port 8000` (cwd `apps/api`).
|
||
|
||
### 3.2 Configuration — `app/core/config.py` (pydantic-settings)
|
||
|
||
Toutes les variables d'environnement sont préfixées `LIFETRACK_`. Un fichier `.env` est lu en dev.
|
||
|
||
```python
|
||
from functools import lru_cache
|
||
|
||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||
|
||
|
||
class Settings(BaseSettings):
|
||
model_config = SettingsConfigDict(
|
||
env_prefix="LIFETRACK_", env_file=".env", extra="ignore"
|
||
)
|
||
|
||
# Database (psycopg v3 driver)
|
||
database_url: str = "postgresql+psycopg://lifetrack:lifetrack@localhost:5432/lifetrack"
|
||
|
||
# Auth
|
||
jwt_secret: str = "change-me-in-env" # MUST be overridden in production
|
||
jwt_algorithm: str = "HS256"
|
||
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days (home deployment)
|
||
|
||
# Time
|
||
timezone: str = "Europe/Paris" # default tz for daily aggregations
|
||
|
||
# Imports
|
||
max_upload_bytes: int = 20 * 1024 * 1024 # 20 MiB
|
||
|
||
# Dev only: Vite dev server origin(s), comma-separated env value
|
||
cors_origins: list[str] = []
|
||
|
||
|
||
@lru_cache
|
||
def get_settings() -> Settings:
|
||
return Settings()
|
||
```
|
||
|
||
Règle : **jamais** de `Settings()` instancié ailleurs — toujours `get_settings()`.
|
||
|
||
### 3.3 Base de données et sessions — `app/core/database.py`
|
||
|
||
```python
|
||
from collections.abc import Iterator
|
||
|
||
from sqlalchemy import create_engine
|
||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||
|
||
from app.core.config import get_settings
|
||
|
||
|
||
class Base(DeclarativeBase):
|
||
"""Single declarative base for the whole application."""
|
||
|
||
|
||
engine = create_engine(get_settings().database_url, pool_pre_ping=True)
|
||
|
||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||
|
||
|
||
def get_db() -> Iterator[Session]:
|
||
"""FastAPI dependency: one session per request, closed automatically."""
|
||
with SessionLocal() as session:
|
||
yield session
|
||
```
|
||
|
||
Style **SQLAlchemy 2.0 typé** obligatoire : `Mapped[...]` + `mapped_column(...)`, requêtes via
|
||
`select()` (jamais `session.query()`).
|
||
|
||
Mixins partagés — `app/core/mixins.py` :
|
||
|
||
```python
|
||
from datetime import datetime
|
||
|
||
from sqlalchemy import DateTime, String, func
|
||
from sqlalchemy.orm import Mapped, mapped_column
|
||
|
||
|
||
class TimestampMixin:
|
||
created_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now()
|
||
)
|
||
updated_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||
)
|
||
|
||
|
||
class SourceMixin:
|
||
"""Provenance + dedupe columns for every imported/ingested row.
|
||
|
||
source: "manual" | importer id ("foodvisor_csv") | ingest source ("android_bridge")
|
||
external_id: stable id from the source system, if any
|
||
content_hash: sha256 of canonical payload, used when no external_id exists
|
||
"""
|
||
source: Mapped[str] = mapped_column(String(50), default="manual")
|
||
external_id: Mapped[str | None] = mapped_column(String(255), default=None)
|
||
content_hash: Mapped[str | None] = mapped_column(String(64), default=None)
|
||
```
|
||
|
||
Chaque table utilisant `SourceMixin` déclare dans `__table_args__` :
|
||
|
||
```python
|
||
UniqueConstraint("user_id", "source", "external_id", name="uq_<table>_external"),
|
||
UniqueConstraint("user_id", "content_hash", name="uq_<table>_hash"),
|
||
```
|
||
|
||
(PostgreSQL autorise plusieurs `NULL` dans un index unique — les saisies manuelles sans
|
||
`external_id`/`content_hash` ne sont donc pas bloquées.)
|
||
|
||
### 3.4 Auto-découverte des modules — `app/core/module_loader.py`
|
||
|
||
**Contrat** : un module backend est un paquet `app/modules/<name>/` contenant au minimum
|
||
`__init__.py` et `router.py` qui expose une variable **`router: APIRouter`**. Fichiers optionnels
|
||
importés automatiquement s'ils existent : `models.py`, `importers.py`, `ingest.py`.
|
||
Personne ne touche `main.py`.
|
||
|
||
```python
|
||
import importlib
|
||
import pkgutil
|
||
|
||
from fastapi import APIRouter, FastAPI
|
||
|
||
from app import modules as modules_pkg
|
||
|
||
# Optional side-effect files imported for every module (models -> metadata,
|
||
# importers/ingest -> registries populated by decorators).
|
||
_SIDE_FILES = ("models", "importers", "ingest")
|
||
|
||
|
||
def iter_module_names() -> list[str]:
|
||
return sorted(
|
||
info.name
|
||
for info in pkgutil.iter_modules(modules_pkg.__path__)
|
||
if not info.name.startswith("_")
|
||
)
|
||
|
||
|
||
def import_side_modules() -> None:
|
||
for name in iter_module_names():
|
||
for side in _SIDE_FILES:
|
||
dotted = f"app.modules.{name}.{side}"
|
||
try:
|
||
importlib.import_module(dotted)
|
||
except ModuleNotFoundError as exc:
|
||
# Only swallow "file does not exist"; re-raise real import errors
|
||
# coming from inside the file (missing dependency, typo…).
|
||
if exc.name != dotted:
|
||
raise
|
||
|
||
|
||
def register_routers(app: FastAPI) -> None:
|
||
for name in iter_module_names():
|
||
mod = importlib.import_module(f"app.modules.{name}.router")
|
||
router = getattr(mod, "router", None)
|
||
if not isinstance(router, APIRouter):
|
||
raise RuntimeError(
|
||
f"Module '{name}' must expose an APIRouter named 'router' in router.py"
|
||
)
|
||
app.include_router(router, prefix="/api")
|
||
```
|
||
|
||
Dans chaque `router.py` :
|
||
|
||
```python
|
||
router = APIRouter(prefix="/health", tags=["health"])
|
||
```
|
||
|
||
Le préfixe standard est `/{nom_du_module}`. Cas particulier autorisé : un `router` racine sans
|
||
préfixe qui agrège plusieurs sous-routeurs (utilisé par `imports` pour exposer à la fois
|
||
`/api/imports/*` et `/api/ingest/*`, voir §5.5).
|
||
|
||
### 3.5 Gestion des erreurs — `app/core/errors.py`
|
||
|
||
Hiérarchie d'exceptions applicatives + handlers globaux garantissant **une forme d'erreur unique**
|
||
(voir §8.3). Les messages `message` sont **en français** (affichés tels quels par le frontend).
|
||
|
||
```python
|
||
from typing import Any
|
||
|
||
from fastapi import FastAPI, Request
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.responses import JSONResponse
|
||
|
||
|
||
class AppError(Exception):
|
||
status_code: int = 400
|
||
code: str = "bad_request"
|
||
|
||
def __init__(self, message: str, *, details: dict[str, Any] | None = None):
|
||
self.message = message
|
||
self.details = details or {}
|
||
super().__init__(message)
|
||
|
||
|
||
class NotFoundError(AppError):
|
||
status_code, code = 404, "not_found"
|
||
|
||
|
||
class ConflictError(AppError):
|
||
status_code, code = 409, "conflict"
|
||
|
||
|
||
class UnauthorizedError(AppError):
|
||
status_code, code = 401, "unauthorized"
|
||
|
||
|
||
class ForbiddenError(AppError):
|
||
status_code, code = 403, "forbidden"
|
||
|
||
|
||
class DomainValidationError(AppError):
|
||
status_code, code = 422, "validation_error"
|
||
|
||
|
||
def _payload(code: str, message: str, details: Any = None) -> dict[str, Any]:
|
||
return {"error": {"code": code, "message": message, "details": details or {}}}
|
||
|
||
|
||
def register_error_handlers(app: FastAPI) -> None:
|
||
@app.exception_handler(AppError)
|
||
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
|
||
return JSONResponse(
|
||
status_code=exc.status_code,
|
||
content=_payload(exc.code, exc.message, exc.details),
|
||
)
|
||
|
||
@app.exception_handler(RequestValidationError)
|
||
async def validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||
return JSONResponse(
|
||
status_code=422,
|
||
content=_payload(
|
||
"validation_error",
|
||
"Les données envoyées sont invalides.",
|
||
{"errors": exc.errors()},
|
||
),
|
||
)
|
||
```
|
||
|
||
Règle : le code de service lève `NotFoundError("Relevé de poids introuvable.")` etc. — **jamais**
|
||
`HTTPException` directement dans les modules.
|
||
|
||
### 3.6 Pagination — `app/core/pagination.py`
|
||
|
||
```python
|
||
from typing import Generic, TypeVar
|
||
|
||
from fastapi import Query
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import Select, func, select
|
||
from sqlalchemy.orm import Session
|
||
|
||
T = TypeVar("T")
|
||
|
||
|
||
class PageParams:
|
||
def __init__(
|
||
self,
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(50, ge=1, le=200),
|
||
):
|
||
self.page = page
|
||
self.page_size = page_size
|
||
|
||
@property
|
||
def offset(self) -> int:
|
||
return (self.page - 1) * self.page_size
|
||
|
||
|
||
class Page(BaseModel, Generic[T]):
|
||
items: list[T]
|
||
total: int
|
||
page: int
|
||
page_size: int
|
||
|
||
|
||
def paginate(db: Session, stmt: Select, params: PageParams) -> tuple[list, int]:
|
||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||
rows = db.scalars(stmt.offset(params.offset).limit(params.page_size)).all()
|
||
return list(rows), total
|
||
```
|
||
|
||
Usage dans un routeur :
|
||
|
||
```python
|
||
@router.get("/weights", response_model=Page[WeightRead])
|
||
def list_weights(
|
||
params: Annotated[PageParams, Depends()],
|
||
db: Annotated[Session, Depends(get_db)],
|
||
user: Annotated[User, Depends(get_current_user)],
|
||
) -> Page[WeightRead]:
|
||
stmt = (
|
||
select(WeightEntry)
|
||
.where(WeightEntry.user_id == user.id)
|
||
.order_by(WeightEntry.measured_at.desc())
|
||
)
|
||
items, total = paginate(db, stmt, params)
|
||
return Page(items=items, total=total, page=params.page, page_size=params.page_size)
|
||
```
|
||
|
||
### 3.7 Dates et fuseaux — `app/core/timeutils.py`
|
||
|
||
```python
|
||
from datetime import UTC, date, datetime
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from app.core.config import get_settings
|
||
from app.core.errors import DomainValidationError
|
||
|
||
|
||
def utcnow() -> datetime:
|
||
return datetime.now(UTC)
|
||
|
||
|
||
def resolve_tz(tz: str | None) -> ZoneInfo:
|
||
name = tz or get_settings().timezone
|
||
try:
|
||
return ZoneInfo(name)
|
||
except Exception as exc:
|
||
raise DomainValidationError(f"Fuseau horaire inconnu : {name}") from exc
|
||
|
||
|
||
def local_day(dt_utc: datetime, tz: ZoneInfo) -> date:
|
||
return dt_utc.astimezone(tz).date()
|
||
```
|
||
|
||
Agrégation « par jour » côté SQL (préférée pour les gros volumes) :
|
||
|
||
```python
|
||
day = func.date(func.timezone(tz_name, Model.measured_at)) # TIMESTAMPTZ -> local date
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Authentification et sécurité
|
||
|
||
### 4.1 Modèles — `app/modules/auth/models.py`
|
||
|
||
```python
|
||
class User(TimestampMixin, Base):
|
||
__tablename__ = "users"
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||
password_hash: Mapped[str] = mapped_column(String(255))
|
||
display_name: Mapped[str] = mapped_column(String(100))
|
||
is_active: Mapped[bool] = mapped_column(default=True)
|
||
|
||
|
||
class DeviceApiKey(TimestampMixin, Base):
|
||
"""Long-lived scoped token for device bridges (Android Health Connect…)."""
|
||
__tablename__ = "device_api_keys"
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||
name: Mapped[str] = mapped_column(String(100)) # ex: "Pixel 8 – pont Health Connect"
|
||
key_prefix: Mapped[str] = mapped_column(String(12), unique=True, index=True)
|
||
key_hash: Mapped[str] = mapped_column(String(64)) # sha256 hex of the full key
|
||
scopes: Mapped[list[str]] = mapped_column(JSON, default=list) # ["ingest:health"]
|
||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||
```
|
||
|
||
### 4.2 Primitives — `app/core/security.py`
|
||
|
||
- **Hachage mots de passe** : `pwdlib` avec recommandation argon2 —
|
||
`password_hasher = PasswordHash.recommended()` ; `hash(pw)` / `verify(pw, hash)`.
|
||
- **JWT** : bibliothèque `PyJWT`. Claims : `sub` (str(user.id)), `iat`, `exp`. Signé HS256 avec
|
||
`settings.jwt_secret`. Fonctions `create_access_token(user_id) -> str` et
|
||
`decode_access_token(token) -> int` (lève `UnauthorizedError` si invalide/expiré).
|
||
- **Clés API d'appareil** : format `ltk_<prefix>_<secret>` où `prefix` = 8 hex aléatoires,
|
||
`secret` = `secrets.token_urlsafe(32)`. On stocke `key_prefix` + `sha256(full_key)`. La clé en
|
||
clair n'est **retournée qu'une seule fois** à la création. Fonctions
|
||
`generate_device_key() -> tuple[full_key, prefix, key_hash]` et
|
||
`verify_device_key(db, full_key) -> DeviceApiKey` (lookup par prefix, comparaison
|
||
`hmac.compare_digest`, vérifie `revoked_at is None`, met à jour `last_used_at`).
|
||
|
||
### 4.3 Dépendances — `app/core/dependencies.py`
|
||
|
||
```python
|
||
bearer = HTTPBearer(auto_error=False)
|
||
|
||
def get_current_user(...) -> User:
|
||
"""JWT Bearer -> User. Raises UnauthorizedError (401) otherwise."""
|
||
|
||
def get_ingest_identity(required_scope: str) -> Callable[..., User]:
|
||
"""Factory dependency for ingest endpoints.
|
||
|
||
Accepts EITHER:
|
||
- Authorization: Bearer <jwt> (interactive user), OR
|
||
- X-API-Key: ltk_... (device key)
|
||
A device key must hold `required_scope` (ex: "ingest:health"),
|
||
or the wildcard "ingest:*". Raises 401/403 accordingly.
|
||
"""
|
||
```
|
||
|
||
Les **scopes** de clé sont des chaînes `ingest:<domain>` (`ingest:health`, `ingest:vape`,
|
||
`ingest:finance`) ou `ingest:*`.
|
||
|
||
### 4.4 Endpoints du module `auth` (préfixe `/api/auth`)
|
||
|
||
| Méthode & chemin | Auth | Description |
|
||
|-------------------------------|-------------|-------------|
|
||
| `GET /auth/status` | publique | `{"setup_required": bool}` — vrai si `users` est vide. Le frontend redirige vers l'assistant `/setup`. |
|
||
| `POST /auth/setup` | publique | Corps `{email, password, display_name}`. Crée le **premier** utilisateur uniquement si aucun n'existe (sinon `409 conflict`). Retourne `{access_token, user}`. |
|
||
| `POST /auth/login` | publique | Corps JSON `{email, password}`. Retourne `{access_token, token_type: "bearer", user}`. Échec : `401` message français générique. |
|
||
| `GET /auth/me` | JWT | Profil de l'utilisateur courant. |
|
||
| `PATCH /auth/me` | JWT | Modifier `display_name` / `email` / mot de passe (`current_password` requis). |
|
||
| `GET /auth/device-keys` | JWT | Liste (sans secret) : id, name, key_prefix, scopes, last_used_at, created_at, revoked_at. |
|
||
| `POST /auth/device-keys` | JWT | Corps `{name, scopes}`. Retourne `{key: "ltk_..."}` **une seule fois** + métadonnées. |
|
||
| `DELETE /auth/device-keys/{id}` | JWT | Révocation (met `revoked_at`, ne supprime pas la ligne). |
|
||
|
||
---
|
||
|
||
## 5. Framework de connecteurs / importeurs
|
||
|
||
Deux voies d'entrée normalisées :
|
||
|
||
1. **Fichiers** (CSV/OFX/exports d'apps) → `BaseImporter`, upload via `POST /api/imports`.
|
||
2. **Poussées JSON** (pont Android, scripts) → `BaseIngestHandler`, `POST /api/ingest/{domain}`.
|
||
|
||
Les deux convergent vers les mêmes tables métier et la même stratégie de déduplication.
|
||
|
||
### 5.1 Types partagés — `app/core/importing/base.py`
|
||
|
||
```python
|
||
from abc import ABC, abstractmethod
|
||
from collections.abc import Iterator
|
||
from dataclasses import dataclass, field
|
||
from enum import StrEnum
|
||
from typing import Any, ClassVar
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
|
||
class UpsertOutcome(StrEnum):
|
||
INSERTED = "inserted"
|
||
UPDATED = "updated" # daily aggregates replaced in place
|
||
DUPLICATE = "duplicate"
|
||
ERROR = "error"
|
||
|
||
|
||
@dataclass
|
||
class NormalizedRecord:
|
||
kind: str # ex: "weight", "food_entry", "transaction"
|
||
data: dict[str, Any] # normalized fields (canonical units, see CONVENTIONS)
|
||
external_id: str | None = None # stable source id if available
|
||
# Fields of `data` used to build content_hash when external_id is None.
|
||
dedupe_fields: tuple[str, ...] = field(default_factory=tuple)
|
||
|
||
|
||
class BaseImporter(ABC):
|
||
"""One importer = one (source, file format) pair. Stateless; instantiated per run."""
|
||
|
||
id: ClassVar[str] # unique snake_case, ex: "foodvisor_csv"
|
||
label: ClassVar[str] # French label shown in UI, ex: "Foodvisor (export CSV)"
|
||
domain: ClassVar[str] # "health" | "vape" | "finance"
|
||
accepted_extensions: ClassVar[tuple[str, ...]] # (".csv",), (".ofx",)…
|
||
|
||
@classmethod
|
||
@abstractmethod
|
||
def sniff(cls, filename: str, head: bytes) -> bool:
|
||
"""Return True if this importer recognizes the file (used when source='auto').
|
||
`head` = first 4096 bytes. Must be cheap and never raise."""
|
||
|
||
@abstractmethod
|
||
def parse(self, data: bytes, filename: str) -> Iterator[NormalizedRecord]:
|
||
"""Decode bytes (handle encodings utf-8/cp1252 and csv dialects) and yield
|
||
normalized records. Raise ImporterParseError for a fatally malformed file;
|
||
yield-level row errors should raise RowError inside iteration."""
|
||
|
||
@abstractmethod
|
||
def upsert(self, db: Session, user_id: int, record: NormalizedRecord) -> UpsertOutcome:
|
||
"""Write one record into the module's tables, honoring dedupe rules."""
|
||
```
|
||
|
||
`hashing.py` :
|
||
|
||
```python
|
||
import hashlib, json
|
||
|
||
def content_hash(record: NormalizedRecord) -> str:
|
||
subset = {k: record.data.get(k) for k in sorted(record.dedupe_fields)}
|
||
payload = json.dumps(subset, sort_keys=True, default=str, ensure_ascii=False)
|
||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||
```
|
||
|
||
**Stratégie de déduplication (normative)** :
|
||
|
||
- Enregistrements « événement » (pesée, transaction, repas, entraînement) :
|
||
- si `external_id` fourni par la source → contrainte unique `(user_id, source, external_id)` ;
|
||
en collision → `DUPLICATE` (skip).
|
||
- sinon → `content_hash` sur `dedupe_fields` → contrainte `(user_id, content_hash)` ;
|
||
en collision → `DUPLICATE`.
|
||
- Enregistrements « agrégat journalier » (`DailyActivity`, `VapeDailyLog`) : clé naturelle
|
||
`(user_id, day, source)` avec **upsert-remplacement** (`INSERT … ON CONFLICT DO UPDATE`,
|
||
dialect `sqlalchemy.dialects.postgresql.insert`) → `UPDATED` si la ligne existait.
|
||
|
||
### 5.2 Registre — `app/core/importing/registry.py`
|
||
|
||
```python
|
||
IMPORTER_REGISTRY: dict[str, type[BaseImporter]] = {}
|
||
|
||
|
||
def register_importer(cls: type[BaseImporter]) -> type[BaseImporter]:
|
||
if cls.id in IMPORTER_REGISTRY:
|
||
raise RuntimeError(f"Duplicate importer id: {cls.id}")
|
||
IMPORTER_REGISTRY[cls.id] = cls
|
||
return cls
|
||
|
||
|
||
def detect_importer(filename: str, head: bytes) -> type[BaseImporter] | None:
|
||
matches = [c for c in IMPORTER_REGISTRY.values() if c.sniff(filename, head)]
|
||
return matches[0] if len(matches) == 1 else None # ambiguous -> force explicit choice
|
||
```
|
||
|
||
Chaque module déclare ses importeurs dans son fichier `importers.py` avec le décorateur
|
||
`@register_importer` — le chargeur (§3.4) importe ces fichiers au démarrage, le registre se
|
||
remplit tout seul.
|
||
|
||
### 5.3 Suivi des imports — `app/modules/imports/models.py`
|
||
|
||
```python
|
||
class ImportRun(TimestampMixin, Base):
|
||
__tablename__ = "import_runs"
|
||
|
||
id: Mapped[int] = mapped_column(primary_key=True)
|
||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||
importer_id: Mapped[str] = mapped_column(String(50)) # ex: "foodvisor_csv"
|
||
domain: Mapped[str] = mapped_column(String(20)) # "health" | "vape" | "finance"
|
||
filename: Mapped[str] = mapped_column(String(255))
|
||
file_size: Mapped[int] = mapped_column()
|
||
status: Mapped[str] = mapped_column(String(20)) # "completed" | "failed"
|
||
rows_total: Mapped[int] = mapped_column(default=0)
|
||
rows_inserted: Mapped[int] = mapped_column(default=0)
|
||
rows_updated: Mapped[int] = mapped_column(default=0)
|
||
rows_duplicates: Mapped[int] = mapped_column(default=0)
|
||
rows_errors: Mapped[int] = mapped_column(default=0)
|
||
error_details: Mapped[list[dict]] = mapped_column(JSON, default=list) # [{row, message}] capped at 100
|
||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||
```
|
||
|
||
`service.py::run_import(db, user, importer_id, filename, data) -> ImportRun` orchestre :
|
||
création du run → boucle `parse()` → `upsert()` par ligne (erreur de ligne = compteur+détail, ne
|
||
stoppe pas le run) → commit unique en fin de run → statut `completed` (ou `failed` si
|
||
`ImporterParseError`). Synchrone en v1 (fichiers personnels, quelques milliers de lignes).
|
||
|
||
### 5.4 Endpoints du module `imports` (préfixe `/api/imports`)
|
||
|
||
| Méthode & chemin | Auth | Description |
|
||
|-------------------------|------|-------------|
|
||
| `GET /imports/sources` | JWT | Importeurs disponibles : `[{id, label, domain, accepted_extensions}]` — alimente le sélecteur de « profil de source » du frontend. |
|
||
| `POST /imports` | JWT | `multipart/form-data` : champ `file` + champ `source` (= `importer_id`, ou `"auto"` pour sniffer). Refus `413` si > `max_upload_bytes`, `422` si source inconnue ou sniff ambigu (message : « Format non reconnu, choisissez un profil de source. »). Retourne l'`ImportRun` complet. |
|
||
| `GET /imports` | JWT | Historique paginé, tri `-started_at`, filtre `?domain=`. |
|
||
| `GET /imports/{id}` | JWT | Détail d'un run (dont `error_details`). |
|
||
|
||
### 5.5 Ingestion JSON générique — `POST /api/ingest/{domain}`
|
||
|
||
Contrat côté core — `app/core/ingest/base.py` :
|
||
|
||
```python
|
||
@dataclass
|
||
class IngestRecord:
|
||
type: str # ex: "steps", "weight", "workout"
|
||
data: dict[str, Any]
|
||
external_id: str | None = None
|
||
|
||
|
||
class BaseIngestHandler(ABC):
|
||
domain: ClassVar[str] # "health"
|
||
record_types: ClassVar[tuple[str, ...]] # accepted `type` values
|
||
|
||
@abstractmethod
|
||
def apply(self, db: Session, user_id: int, record: IngestRecord) -> UpsertOutcome:
|
||
...
|
||
```
|
||
|
||
Registre `INGEST_REGISTRY: dict[str, BaseIngestHandler]` + décorateur
|
||
`@register_ingest_handler` (mêmes règles que les importeurs ; déclaré dans `ingest.py` du module).
|
||
|
||
Le routeur du module `imports` expose (agrégation de deux sous-routeurs, cas particulier
|
||
du contrat §3.4) :
|
||
|
||
```python
|
||
router = APIRouter()
|
||
router.include_router(imports_router, prefix="/imports", tags=["imports"])
|
||
router.include_router(ingest_router, prefix="/ingest", tags=["ingest"])
|
||
```
|
||
|
||
Requête (`POST /api/ingest/health`, en-tête `X-API-Key: ltk_...` **ou** JWT) :
|
||
|
||
```json
|
||
{
|
||
"source": "android_bridge",
|
||
"records": [
|
||
{"type": "steps", "external_id": "hc:2026-08-12", "data": {"day": "2026-08-12", "steps": 9421, "calories_kcal": 2350, "distance_m": 6800}},
|
||
{"type": "weight", "external_id": "hc:w:1755012345", "data": {"measured_at": "2026-08-13T06:31:00Z", "weight_kg": 91.4}}
|
||
]
|
||
}
|
||
```
|
||
|
||
Réponse `200` :
|
||
|
||
```json
|
||
{
|
||
"domain": "health",
|
||
"received": 2,
|
||
"inserted": 1,
|
||
"updated": 1,
|
||
"duplicates": 0,
|
||
"errors": []
|
||
}
|
||
```
|
||
|
||
Chaque enregistrement est traité indépendamment ; une erreur unitaire est reportée dans
|
||
`errors: [{index, type, message}]` sans faire échouer le lot. Domaine inconnu → `404`,
|
||
`type` non supporté → erreur unitaire. Limite : 1 000 enregistrements par requête (`422` au-delà).
|
||
|
||
### 5.6 Importeurs v1 à implémenter
|
||
|
||
| id | domaine | format | Notes de parsing |
|
||
|-----------------------|----------|--------|------------------|
|
||
| `foodvisor_csv` | health | CSV | Export Foodvisor : lignes repas/aliments → `FoodEntry` (kcal, macros). Sniff : en-têtes caractéristiques Foodvisor. |
|
||
| `health_connect_csv` | health | CSV | Export CSV générique du pont Health Connect (pas/jour, poids, calories) — mêmes types que l'ingestion JSON. |
|
||
| `fitshow_csv` | health | CSV | Séances tapis exportées de FitShow → `Workout` (sport="treadmill", durée, distance, kcal). |
|
||
| `weight_generic_csv` | health | CSV | Deux colonnes `date;poids` (saisie historique de l'utilisateur). |
|
||
| `bank_generic_csv` | finance | CSV | Relevés banques françaises : séparateur `;`, décimale virgule, encodage cp1252/utf-8 (détection BOM puis fallback), colonnes date/libellé/débit/crédit ou montant signé. Mapping colonnes tolérant (recherche d'en-têtes normalisés sans accents). |
|
||
| `ofx` | finance | OFX | Bibliothèque `ofxparse` ; `external_id` = FITID. |
|
||
| `paypal_csv` | finance | CSV | Export « Activité » PayPal ; `external_id` = code de transaction. |
|
||
|
||
Chaque importeur vit dans `importers.py` du module concerné. Le compte bancaire cible est déduit
|
||
du fichier quand c'est possible (OFX), sinon un compte `"Compte importé"` est créé par défaut ; le
|
||
frontend permettra de re-router plus tard (hors périmètre v1 : choix de compte dans l'upload via
|
||
champ optionnel `account_id`).
|
||
|
||
---
|
||
|
||
## 6. Modules métier — modèles et endpoints
|
||
|
||
Tous les modèles ci-dessous héritent de `Base`, `TimestampMixin`, et de `SourceMixin` quand la
|
||
donnée peut provenir d'un import (règle : toute table alimentée par importeur/ingestion porte
|
||
`SourceMixin` + les deux contraintes uniques du §3.3). Toutes les tables portent
|
||
`user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)`.
|
||
|
||
### 6.1 `health` (préfixe `/api/health`)
|
||
|
||
**Modèles** (`models.py`) :
|
||
|
||
| Table | Colonnes clés |
|
||
|-------|---------------|
|
||
| `health_profiles` | `user_id` (unique), `height_cm: float`, `birth_date: date`, `sex: str` ("male"/"female"), `activity_level: str` ("sedentary"/"light"/"moderate"/"active"/"very_active"), `target_weight_kg: float`, `target_date: date \| None`, `calorie_budget_override_kcal: int \| None` |
|
||
| `weight_entries` | `measured_at: datetime(tz)`, `weight_kg: float`, `body_fat_pct: float \| None` + SourceMixin |
|
||
| `body_measurements` | `measured_at: datetime(tz)`, `site: str` ("waist","hips","chest","thigh","arm","neck","calf"), `value_cm: float` + SourceMixin |
|
||
| `daily_activities` | `day: date` (**jour local**), `steps: int`, `calories_kcal: float`, `distance_m: float`, `active_minutes: int \| None` + SourceMixin ; unique `(user_id, day, source)` ; upsert-remplacement |
|
||
| `workouts` | `started_at: datetime(tz)`, `duration_s: int`, `sport: str` ("treadmill","running","cycling","strength","other"…), `distance_m: float \| None`, `calories_kcal: float \| None`, `avg_heart_rate: int \| None`, `notes: str \| None` + SourceMixin |
|
||
| `food_entries` | `eaten_at: datetime(tz)`, `meal: str` ("breakfast","lunch","dinner","snack"), `label: str`, `quantity: str \| None`, `calories_kcal: float`, `protein_g/carbs_g/fat_g: float \| None` + SourceMixin |
|
||
|
||
**Calculs** (`calculations.py`) — formules normatives :
|
||
|
||
- **BMR** (Mifflin-St Jeor) : `10*poids_kg + 6.25*taille_cm − 5*âge + (5 si homme, −161 si femme)`.
|
||
- **TDEE** = BMR × facteur (`sedentary` 1.2, `light` 1.375, `moderate` 1.55, `active` 1.725,
|
||
`very_active` 1.9). Le poids utilisé est la dernière pesée connue.
|
||
- **Balance énergétique du jour** = `kcal ingérées (food_entries)` −
|
||
(`BMR` + `calories actives` où calories actives = max(daily_activities.calories_kcal du jour
|
||
toutes sources confondues — priorité à la source la plus complète : `manual` > pont > import) ;
|
||
si la source fournit des calories **totales** brûlées, le pont doit envoyer le champ
|
||
`calories_kcal` comme *actives* — convention du connecteur).
|
||
- **Budget calorique quotidien** = `TDEE − déficit_visé` où
|
||
`déficit_visé = (poids_actuel − target_weight_kg) * 7700 / jours_restants` si `target_date`
|
||
définie, plafonné à 1 000 kcal/j ; sinon défaut 500 kcal/j ;
|
||
`calorie_budget_override_kcal` court-circuite tout.
|
||
- **Projection** : à rythme = moyenne mobile 21 jours de la variation de poids, date estimée
|
||
d'atteinte de l'objectif ; et courbe théorique via déficit moyen 14 jours / 7700 kcal par kg.
|
||
|
||
**Endpoints** :
|
||
|
||
| Méthode & chemin | Description |
|
||
|------------------|-------------|
|
||
| `GET/PUT /health/profile` | Profil santé (création à la première écriture). |
|
||
| CRUD `GET/POST /health/weights`, `PATCH/DELETE /health/weights/{id}` | Pesées ; liste paginée, filtres `?from=&to=` (dates ISO). |
|
||
| CRUD `/health/measurements` (+ `?site=`) | Mensurations. |
|
||
| `GET /health/activity?from=&to=&tz=` | Activité journalière fusionnée (une ligne/jour, meilleure source). |
|
||
| `POST /health/activity` | Saisie/écrasement manuel d'un jour. |
|
||
| CRUD `/health/workouts` | Entraînements. |
|
||
| CRUD `/health/food` (+ `?day=&tz=`) | Journal alimentaire. |
|
||
| `GET /health/summary/daily?from=&to=&tz=` | Par jour : poids (dernière pesée ≤ jour), kcal in, kcal actives, BMR, TDEE, balance, budget, pas. **Endpoint principal des graphiques.** |
|
||
| `GET /health/goal` | Objectif : poids actuel/cible, progrès %, budget du jour, date projetée, série de projection. |
|
||
|
||
### 6.2 `vape` (préfixe `/api/vape`)
|
||
|
||
**Modèles** :
|
||
|
||
| Table | Colonnes clés |
|
||
|-------|---------------|
|
||
| `vape_cost_models` | `valid_from: date`, prix du DIY : `base_price_eur: Numeric(8,2)` + `base_volume_ml: float` (base PG/VG), `booster_price_eur` + `booster_volume_ml` + `booster_strength_mg_ml: float`, `aroma_price_eur` + `aroma_volume_ml`, `aroma_rate_pct: float`, `target_nicotine_mg_ml: float`, `coil_price_eur: Numeric(8,2)`, `coil_pack_size: int` — historisé : le modèle actif à une date D est celui au `valid_from` le plus récent ≤ D. |
|
||
| `vape_daily_logs` | `day: date` (jour local), `eliquid_ml: float`, `notes: str \| None` + SourceMixin ; unique `(user_id, day, source)` ; upsert-remplacement. |
|
||
| `coil_changes` | `changed_at: datetime(tz)`, `coil_model: str \| None`, `notes: str \| None` + SourceMixin. |
|
||
| `smoking_baselines` | `user_id` (unique), `cigarettes_per_day: float`, `pack_price_eur: Numeric(6,2)`, `pack_size: int` (défaut 20), `quit_date: date`. |
|
||
|
||
**Calculs** (`calculations.py`) :
|
||
|
||
- **Coût du e-liquide par ml** (pour un batch théorique de 1 000 ml au taux cible `n` mg/ml) :
|
||
`booster_ml = 1000 * n / booster_strength_mg_ml` ;
|
||
`aroma_ml = 1000 * aroma_rate_pct / 100` ;
|
||
`base_ml = 1000 − booster_ml − aroma_ml` ;
|
||
`cost_1000 = base_ml * (base_price/base_volume) + booster_ml * (booster_price/booster_volume) + aroma_ml * (aroma_price/aroma_volume)` ;
|
||
`cost_per_ml = cost_1000 / 1000`.
|
||
- **Nicotine quotidienne (mg)** = `eliquid_ml * target_nicotine_mg_ml` (modèle actif du jour).
|
||
- **Durée de vie moyenne d'une résistance** = moyenne des intervalles entre `coil_changes`
|
||
consécutifs (fenêtre : 10 derniers changements) ; coût résistance/jour =
|
||
`(coil_price_eur / coil_pack_size) / durée_moyenne_jours`.
|
||
- **Coût vape/jour** = `eliquid_ml * cost_per_ml + coût_résistance_jour`.
|
||
- **Coût tabac évité/jour** = `cigarettes_per_day * pack_price_eur / pack_size`.
|
||
- **Économies cumulées** depuis `quit_date` =
|
||
`Σ_jour (coût_tabac_évité − coût_vape_jour)` — les jours sans log de vape comptent le coût
|
||
moyen des 30 derniers jours loggés (ou 0 si aucun log).
|
||
|
||
**Endpoints** : `GET/PUT /vape/baseline` ; `GET/POST /vape/cost-models` (liste historisée,
|
||
`GET /vape/cost-models/current`) ; CRUD `/vape/logs` (+ `?from=&to=`) ; CRUD `/vape/coils` +
|
||
`GET /vape/coils/stats` (durée de vie moyenne, dernier changement, prévision prochain) ;
|
||
`GET /vape/summary?from=&to=` (par jour : ml, mg nicotine, coût) ;
|
||
`GET /vape/savings` (cumul, moyenne/jour, équivalent cigarettes non fumées).
|
||
|
||
### 6.3 `finance` (préfixe `/api/finance`)
|
||
|
||
**Modèles** :
|
||
|
||
| Table | Colonnes clés |
|
||
|-------|---------------|
|
||
| `accounts` | `name: str`, `kind: str` ("bank","paypal","cash","other"), `currency: str` (défaut "EUR"), `iban_suffix: str \| None` |
|
||
| `categories` | `name: str`, `parent_id: FK categories \| None`, `color: str` (hex), `icon: str \| None` ; unique `(user_id, parent_id, name)` |
|
||
| `transactions` | `account_id: FK`, `posted_at: date`, `amount_cents: int` (**signé** : dépense < 0), `currency: str`, `label_raw: str`, `label_clean: str` (normalisé : majuscules/espaces/numéros repliés), `category_id: FK \| None`, `notes: str \| None`, `import_run_id: FK \| None` + SourceMixin |
|
||
| `category_rules` | `pattern: str`, `match_type: str` ("contains" \| "regex"), `field: str` ("label_clean"), `category_id: FK`, `priority: int` (petit = prioritaire), `is_active: bool` |
|
||
| `budgets` | `category_id: FK`, `amount_cents: int` (> 0), `period: str` ("monthly"), `starts_month: str "YYYY-MM"`, `ends_month: str \| None` |
|
||
|
||
**Logique** (`categorize.py`) :
|
||
|
||
- `apply_rules(db, user_id, transactions)` : première règle active qui matche (ordre `priority`
|
||
puis `id`) fixe `category_id` ; ne réécrit jamais une catégorie posée manuellement
|
||
(une colonne `category_locked: bool` sur `transactions`, mise à vrai lors d'une
|
||
catégorisation manuelle).
|
||
- Les règles s'appliquent automatiquement à la fin de chaque import et via
|
||
`POST /finance/rules/apply` (re-catégorise tout le non-verrouillé).
|
||
- **Détection de récurrences** (calcul à la volée, pas de table) : groupement par `label_clean`,
|
||
≥ 3 occurrences, montants dans ±10 % de la médiane, intervalle médian ∈ {7±2, 14±3, 30±5,
|
||
365±15} jours → renvoie libellé, montant médian, périodicité, dernière occurrence, prochaine
|
||
échéance estimée.
|
||
|
||
**Endpoints** : CRUD `/finance/accounts` ; `GET /finance/transactions` (paginé ; filtres
|
||
`?account_id=&category_id=&from=&to=&q=&uncategorized=true` ; tri `-posted_at`),
|
||
`POST /finance/transactions` (saisie manuelle), `PATCH /finance/transactions/{id}`
|
||
(catégorie → verrouille), `DELETE` ; CRUD `/finance/categories` (arbre) ; CRUD `/finance/rules` +
|
||
`POST /finance/rules/apply` ; CRUD `/finance/budgets` ;
|
||
`GET /finance/summary/monthly?from=YYYY-MM&to=YYYY-MM` (par mois × catégorie : dépensé, budget,
|
||
delta) ; `GET /finance/recurring`.
|
||
|
||
**Montants** : toujours en **centimes entiers** (`amount_cents`). Le frontend formate en euros
|
||
`fr-FR` (`format.ts::formatEuros(cents)`).
|
||
|
||
---
|
||
|
||
## 7. Frontend — structure React
|
||
|
||
### 7.1 Contrat de module — `src/types/module.ts`
|
||
|
||
```ts
|
||
import type { RouteObject } from "react-router-dom";
|
||
import type { LucideIcon } from "lucide-react";
|
||
|
||
export interface NavItem {
|
||
path: string; // absolute path, ex: "/sante/poids"
|
||
label: string; // French label, ex: "Poids"
|
||
icon?: LucideIcon; // lucide-react icon component
|
||
order: number; // sort key inside the sidebar section
|
||
}
|
||
|
||
export interface ModuleManifest {
|
||
id: string; // English module id, ex: "health" (MUST match folder name)
|
||
title: string; // French section title, ex: "Santé"
|
||
order: number; // sidebar section order (home=0, health=10, vape=20,
|
||
// finance=30, imports=80, settings=90)
|
||
routes: RouteObject[]; // mounted as children of the protected AppLayout route
|
||
nav: NavItem[]; // sidebar entries (may be empty)
|
||
}
|
||
```
|
||
|
||
### 7.2 Auto-découverte — `src/app/modules.ts`
|
||
|
||
```ts
|
||
import type { ModuleManifest } from "../types/module";
|
||
|
||
// Eagerly load every module manifest. A module = src/modules/<name>/index.ts
|
||
// whose DEFAULT export is a ModuleManifest. Nobody edits this file.
|
||
const files = import.meta.glob<{ default: ModuleManifest }>(
|
||
"../modules/*/index.ts",
|
||
{ eager: true },
|
||
);
|
||
|
||
export const modules: ModuleManifest[] = Object.values(files)
|
||
.map((m) => m.default)
|
||
.filter(Boolean)
|
||
.sort((a, b) => a.order - b.order);
|
||
```
|
||
|
||
### 7.3 Routeur — `src/app/router.tsx`
|
||
|
||
```ts
|
||
export const router = createBrowserRouter([
|
||
{ path: "/login", element: <LoginPage /> },
|
||
{ path: "/setup", element: <SetupPage /> },
|
||
{
|
||
element: <ProtectedRoute><AppLayout /></ProtectedRoute>,
|
||
children: [
|
||
...modules.flatMap((m) => m.routes),
|
||
{ path: "*", element: <NotFoundPage /> },
|
||
],
|
||
},
|
||
]);
|
||
```
|
||
|
||
`ProtectedRoute` : au montage, interroge `GET /api/auth/status` (mise en cache React Query) —
|
||
si `setup_required` → redirection `/setup` ; sinon si pas de token → `/login`.
|
||
|
||
Exemple de manifeste — `src/modules/health/index.ts` :
|
||
|
||
```ts
|
||
import { Activity, Dumbbell, Ruler, Scale, Utensils, HeartPulse } from "lucide-react";
|
||
import type { ModuleManifest } from "../../types/module";
|
||
// pages imported with React.lazy for code-splitting
|
||
const manifest: ModuleManifest = {
|
||
id: "health",
|
||
title: "Santé",
|
||
order: 10,
|
||
routes: [
|
||
{ path: "/sante", element: <HealthDashboardPage /> },
|
||
{ path: "/sante/poids", element: <WeightPage /> },
|
||
{ path: "/sante/mesures", element: <MeasurementsPage /> },
|
||
{ path: "/sante/activite", element: <ActivityPage /> },
|
||
{ path: "/sante/entrainements", element: <WorkoutsPage /> },
|
||
{ path: "/sante/nutrition", element: <NutritionPage /> },
|
||
],
|
||
nav: [
|
||
{ path: "/sante", label: "Tableau de bord", icon: HeartPulse, order: 0 },
|
||
{ path: "/sante/poids", label: "Poids", icon: Scale, order: 1 },
|
||
{ path: "/sante/mesures", label: "Mensurations", icon: Ruler, order: 2 },
|
||
{ path: "/sante/activite", label: "Activité", icon: Activity, order: 3 },
|
||
{ path: "/sante/entrainements", label: "Entraînements", icon: Dumbbell, order: 4 },
|
||
{ path: "/sante/nutrition", label: "Nutrition", icon: Utensils, order: 5 },
|
||
],
|
||
};
|
||
export default manifest;
|
||
```
|
||
|
||
La `Sidebar` itère `modules` : titre de section = `title`, entrées = `nav` triées par `order`.
|
||
**URLs français** (visibles par l'utilisateur), **ids/fichiers anglais**.
|
||
|
||
### 7.4 Client API — `src/lib/api.ts`
|
||
|
||
```ts
|
||
export interface ApiErrorShape {
|
||
error: { code: string; message: string; details: Record<string, unknown> };
|
||
}
|
||
|
||
export class ApiError extends Error {
|
||
constructor(
|
||
public status: number,
|
||
public code: string,
|
||
message: string,
|
||
public details: Record<string, unknown> = {},
|
||
) { super(message); }
|
||
}
|
||
|
||
const TOKEN_KEY = "lifetrack.token";
|
||
export const getToken = () => localStorage.getItem(TOKEN_KEY);
|
||
export const setToken = (t: string | null) =>
|
||
t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY);
|
||
|
||
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||
const headers = new Headers(init.headers);
|
||
if (!(init.body instanceof FormData)) headers.set("Content-Type", "application/json");
|
||
const token = getToken();
|
||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||
|
||
const res = await fetch(`/api${path}`, { ...init, headers });
|
||
if (res.status === 401) {
|
||
setToken(null);
|
||
window.location.assign("/login");
|
||
throw new ApiError(401, "unauthorized", "Session expirée, veuillez vous reconnecter.");
|
||
}
|
||
if (!res.ok) {
|
||
const body = (await res.json().catch(() => null)) as ApiErrorShape | null;
|
||
throw new ApiError(
|
||
res.status,
|
||
body?.error.code ?? "unknown_error",
|
||
body?.error.message ?? "Une erreur est survenue.",
|
||
body?.error.details ?? {},
|
||
);
|
||
}
|
||
return res.status === 204 ? (undefined as T) : ((await res.json()) as T);
|
||
}
|
||
```
|
||
|
||
React Query : `queryClient` unique (`staleTime: 30_000`, `retry: 1`). Chaque module définit ses
|
||
hooks dans `api.ts` du module :
|
||
|
||
```ts
|
||
// src/modules/health/api.ts
|
||
export const healthKeys = {
|
||
weights: (p?: object) => ["health", "weights", p ?? {}] as const,
|
||
summary: (p: object) => ["health", "summary", p] as const,
|
||
};
|
||
|
||
export function useWeights(params: { page?: number; from?: string; to?: string }) {
|
||
return useQuery({
|
||
queryKey: healthKeys.weights(params),
|
||
queryFn: () => api<Page<WeightRead>>(`/health/weights?${qs(params)}`),
|
||
});
|
||
}
|
||
```
|
||
|
||
Convention de clés : `[moduleId, resource, params]`. Les mutations invalident
|
||
`[moduleId, resource]`.
|
||
|
||
### 7.5 Thème sombre Tailwind
|
||
|
||
- Tailwind v3.4, `darkMode: "class"` ; la classe `dark` est posée sur `<html>` dans
|
||
`index.html` (thème sombre **par défaut et unique** en v1).
|
||
- Palette : fond `slate-950`, surfaces `slate-900`, bordures `slate-800`, texte `slate-100`
|
||
(secondaire `slate-400`), accent principal `emerald-500`, accents module : health `emerald`,
|
||
vape `violet`, finance `sky`, alertes `rose-500` / `amber-400`.
|
||
- `styles/index.css` : directives `@tailwind`, variables CSS `--color-accent` par module
|
||
optionnelles, scrollbars fines, `font-family: Inter, system-ui, sans-serif` (Inter en
|
||
fichier local `public/fonts/`, pas de CDN).
|
||
|
||
### 7.6 Wrapper ECharts — `src/components/charts/EChart.tsx`
|
||
|
||
- Imports **tree-shakés** : `echarts/core` + `LineChart, BarChart, PieChart, ScatterChart,
|
||
HeatmapChart` + composants `GridComponent, TooltipComponent, LegendComponent,
|
||
DataZoomComponent, MarkLineComponent` + `CanvasRenderer` ; `echarts.use([...])` une fois
|
||
dans `theme.ts`.
|
||
- `theme.ts` : `echarts.registerTheme("lifetrack-dark", {...})` aligné sur la palette Tailwind.
|
||
- Composant : props `{ option: EChartsOption; height?: number | string; loading?: boolean;
|
||
onEvents?: Record<string, (p: unknown) => void> }` ; init au montage avec le thème,
|
||
`ResizeObserver` sur le conteneur → `chart.resize()`, `chart.setOption(option, {
|
||
notMerge: true })` sur changement, `chart.dispose()` au démontage.
|
||
- Formatage des axes/tooltips en `fr-FR` via `lib/format.ts` (ex : `1 234,5 kcal`, `86,4 kg`,
|
||
`12,50 €`) — jamais de format anglais.
|
||
|
||
### 7.7 Écrans clés v1 (rappel produit)
|
||
|
||
- **Accueil** (`/`) : widgets — poids actuel + tendance 30 j, budget kcal du jour et reste,
|
||
balance énergétique de la veille, économies vape cumulées, dépenses du mois vs budgets.
|
||
- **Santé** : courbe de poids + objectif + projection (markLine cible), journal du jour
|
||
(kcal in/out), historique activité (barres pas/kcal), CRUD listes.
|
||
- **Vape** : conso ml/jour (barres), nicotine mg/jour, coût/jour, compteur d'économies (« Vous
|
||
avez économisé X € depuis le DD/MM/YYYY »), gestion résistances, formulaire modèle de coût.
|
||
- **Finances** : liste transactions avec filtres + édition de catégorie inline, donut dépenses
|
||
par catégorie du mois, barres budget vs réel, page récurrences.
|
||
- **Imports** : zone de dépôt de fichier + sélection du profil de source (liste depuis
|
||
`/imports/sources`, option « Détection automatique »), tableau des runs avec compteurs
|
||
(insérés / doublons / erreurs) et détail des erreurs.
|
||
- **Paramètres** : profil utilisateur, changement de mot de passe, clés d'appareil (création
|
||
avec affichage unique de la clé, révocation), profil santé, baseline tabac.
|
||
|
||
---
|
||
|
||
## 8. Conventions d'API
|
||
|
||
### 8.1 Généralités
|
||
|
||
- Préfixe global `/api` (posé par le chargeur) ; préfixe module `/{module}` ; ressources au
|
||
**pluriel anglais** (`/api/health/weights`). OpenAPI : `/api/docs`, `/api/openapi.json` ;
|
||
chaque routeur porte `tags=[module]` (les `summary` d'endpoints peuvent être en français).
|
||
- JSON uniquement (`application/json`), sauf upload (`multipart/form-data`).
|
||
- Verbes : `GET` lecture, `POST` création/actions, `PATCH` mise à jour partielle,
|
||
`PUT` remplacement de singleton (profil), `DELETE` suppression → `204 No Content`.
|
||
- Création → `201` avec l'objet créé (schéma `*Read`).
|
||
|
||
### 8.2 Pagination, tri, filtres
|
||
|
||
- Listes : `?page=` (défaut 1) et `?page_size=` (défaut 50, max 200) ; réponse
|
||
`{"items": [...], "total": n, "page": p, "page_size": s}`.
|
||
- Tri : `?sort=champ` ou `?sort=-champ` (desc) ; champs autorisés listés par endpoint, défaut
|
||
documenté (généralement date desc).
|
||
- Filtres temporels : `?from=` / `?to=` inclusifs — `date` (YYYY-MM-DD) pour les ressources à
|
||
jour local, datetime ISO pour les ressources horodatées.
|
||
|
||
### 8.3 Forme d'erreur (unique)
|
||
|
||
```json
|
||
{ "error": { "code": "not_found", "message": "Transaction introuvable.", "details": {} } }
|
||
```
|
||
|
||
- `code` : identifiant machine `snake_case` stable ; `message` : phrase **française** affichable
|
||
telle quelle ; `details` : objet libre (erreurs de validation, index de ligne…).
|
||
- Statuts : 400 `bad_request`, 401 `unauthorized`, 403 `forbidden`, 404 `not_found`,
|
||
409 `conflict`, 413 `payload_too_large`, 422 `validation_error`, 500 `internal_error`
|
||
(message générique, détails loggés côté serveur uniquement).
|
||
|
||
### 8.4 Dates, fuseaux, unités, monnaie
|
||
|
||
- Datetimes API : ISO 8601 **UTC suffixe `Z`** en entrée/sortie ; entrée avec offset acceptée et
|
||
convertie en UTC ; entrée naïve **refusée** (422).
|
||
- Dates « jour local » : `YYYY-MM-DD`.
|
||
- Endpoints d'agrégation journalière : paramètre `?tz=` (défaut `Europe/Paris` via settings) —
|
||
le regroupement par jour se fait dans ce fuseau.
|
||
- Unités canoniques stockées : kg, cm, ml, mg, kcal, mètres, secondes, centimes d'euro.
|
||
Toute conversion d'affichage est côté frontend.
|
||
|
||
---
|
||
|
||
## 9. Docker et déploiement
|
||
|
||
### 9.1 `docker/api.Dockerfile` (multi-étapes)
|
||
|
||
```dockerfile
|
||
FROM python:3.12-slim AS builder
|
||
WORKDIR /build
|
||
COPY apps/api/requirements.txt .
|
||
RUN python -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir -r requirements.txt
|
||
|
||
FROM python:3.12-slim
|
||
ENV PATH="/opt/venv/bin:$PATH" PYTHONUNBUFFERED=1
|
||
WORKDIR /srv
|
||
COPY --from=builder /opt/venv /opt/venv
|
||
COPY apps/api/app ./app
|
||
EXPOSE 8000
|
||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/healthz')"
|
||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||
```
|
||
|
||
### 9.2 `docker/web.Dockerfile`
|
||
|
||
```dockerfile
|
||
FROM node:20-alpine AS build
|
||
WORKDIR /build
|
||
COPY apps/web/package*.json ./
|
||
RUN npm ci
|
||
COPY apps/web .
|
||
RUN npm run build
|
||
|
||
FROM nginx:1.27-alpine
|
||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||
COPY --from=build /build/dist /usr/share/nginx/html
|
||
EXPOSE 80
|
||
```
|
||
|
||
### 9.3 `docker/nginx.conf`
|
||
|
||
```nginx
|
||
server {
|
||
listen 80;
|
||
server_name _;
|
||
client_max_body_size 25m; # uploads d'imports
|
||
|
||
root /usr/share/nginx/html;
|
||
index index.html;
|
||
|
||
location /api/ {
|
||
proxy_pass http://api:8000; # no trailing slash: /api prefix is preserved
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
proxy_read_timeout 120s; # synchronous imports can take a while
|
||
}
|
||
|
||
location / {
|
||
try_files $uri $uri/ /index.html; # SPA fallback
|
||
}
|
||
}
|
||
```
|
||
|
||
### 9.4 `docker-compose.yml` (production maison)
|
||
|
||
```yaml
|
||
services:
|
||
postgres:
|
||
image: postgres:16-alpine
|
||
restart: unless-stopped
|
||
environment:
|
||
POSTGRES_USER: ${POSTGRES_USER}
|
||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||
POSTGRES_DB: ${POSTGRES_DB}
|
||
volumes:
|
||
- pgdata:/var/lib/postgresql/data
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 5
|
||
|
||
api:
|
||
build:
|
||
context: .
|
||
dockerfile: docker/api.Dockerfile
|
||
restart: unless-stopped
|
||
environment:
|
||
LIFETRACK_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||
LIFETRACK_JWT_SECRET: ${LIFETRACK_JWT_SECRET}
|
||
LIFETRACK_TIMEZONE: ${LIFETRACK_TIMEZONE:-Europe/Paris}
|
||
depends_on:
|
||
postgres:
|
||
condition: service_healthy
|
||
|
||
web:
|
||
build:
|
||
context: .
|
||
dockerfile: docker/web.Dockerfile
|
||
restart: unless-stopped
|
||
ports:
|
||
- "${WEB_PORT:-80}:80"
|
||
depends_on:
|
||
- api
|
||
|
||
volumes:
|
||
pgdata:
|
||
```
|
||
|
||
**Mode dev** : `docker compose up postgres` seulement, puis en local :
|
||
`uvicorn app.main:app --reload --port 8000` (cwd `apps/api`, `.env` à la racine du repo copié ou
|
||
`LIFETRACK_DATABASE_URL` pointant `localhost:5432`) et `npm run dev` (Vite, port 5173).
|
||
`vite.config.ts` proxifie :
|
||
|
||
```ts
|
||
server: { proxy: { "/api": "http://localhost:8000" } }
|
||
```
|
||
|
||
(ainsi aucun besoin de CORS en dev via proxy ; `LIFETRACK_CORS_ORIGINS=http://localhost:5173`
|
||
reste disponible si le proxy n'est pas utilisé). Un `docker-compose.dev.yml` optionnel monte les
|
||
sources et lance `uvicorn --reload` / `vite` dans des conteneurs pour ceux qui préfèrent.
|
||
|
||
### 9.5 `.env.example` (racine)
|
||
|
||
```env
|
||
# PostgreSQL
|
||
POSTGRES_USER=lifetrack
|
||
POSTGRES_PASSWORD=change-me
|
||
POSTGRES_DB=lifetrack
|
||
|
||
# API
|
||
LIFETRACK_JWT_SECRET=generate-a-long-random-string # ex: openssl rand -hex 32
|
||
LIFETRACK_TIMEZONE=Europe/Paris
|
||
# Dev only (Vite without proxy): comma-separated origins
|
||
# LIFETRACK_CORS_ORIGINS=["http://localhost:5173"]
|
||
|
||
# Web
|
||
WEB_PORT=80
|
||
```
|
||
|
||
### 9.6 Dépendances
|
||
|
||
`apps/api/requirements.txt` :
|
||
|
||
```text
|
||
fastapi>=0.111
|
||
uvicorn[standard]>=0.30
|
||
sqlalchemy>=2.0.30
|
||
psycopg[binary]>=3.1
|
||
pydantic>=2.7
|
||
pydantic-settings>=2.2
|
||
pwdlib[argon2]>=0.2
|
||
PyJWT>=2.8
|
||
python-multipart>=0.0.9
|
||
ofxparse>=0.21
|
||
```
|
||
|
||
`requirements-dev.txt` : `pytest`, `pytest-cov`, `httpx`, `ruff`.
|
||
|
||
`apps/web/package.json` (principales) : `react`, `react-dom`, `react-router-dom@6`,
|
||
`@tanstack/react-query@5`, `echarts`, `lucide-react`, `tailwindcss@3`, `typescript`, `vite`,
|
||
`@vitejs/plugin-react`.
|
||
|
||
### 9.7 Découpage du travail en chantiers parallèles
|
||
|
||
1. **Socle backend** : `core/*`, module `auth`, `main.py`, Docker/compose — bloquant pour le reste.
|
||
2. **Socle frontend** : coquille (`app/*`, `lib/*`, `components/*`, login/setup) — bloquant côté web.
|
||
3. Ensuite en parallèle, un agent par module : `health` (API+web), `vape` (API+web),
|
||
`finance` (API+web), `imports` (framework §5 + API + web). Grâce à l'auto-découverte,
|
||
**aucun conflit de fichier** entre chantiers 3+.
|
||
|
||
---
|
||
|
||
## 10. CONVENTIONS (à extraire verbatim dans `CONVENTIONS.md`)
|
||
|
||
> Règles obligatoires pour tout développeur (humain ou agent) ajoutant ou modifiant un module
|
||
> LifeTrack. En cas de doute, ce document fait foi.
|
||
|
||
### C1. Langues
|
||
|
||
- **Code** (identifiants, fichiers, tables, colonnes, routes API, commentaires, messages de
|
||
commit) : **anglais**.
|
||
- **UI et messages d'erreur destinés à l'utilisateur** (`AppError(message=...)`, labels,
|
||
`strings.ts`, docs utilisateur) : **français**, ponctuation française correcte, pas d'anglicismes
|
||
gratuits. Formats d'affichage `fr-FR` (virgule décimale, espace insécable des milliers, `€`
|
||
après le montant).
|
||
|
||
### C2. Créer un module backend
|
||
|
||
1. Créer `apps/api/app/modules/<name>/` avec `__init__.py` (vide) et **obligatoirement**
|
||
`router.py` exposant `router = APIRouter(prefix="/<name>", tags=["<name>"])`.
|
||
Fichiers standard : `models.py`, `schemas.py`, `service.py` ; optionnels : `importers.py`,
|
||
`ingest.py`, `calculations.py`.
|
||
2. **Ne jamais modifier** `main.py`, `module_loader.py`, ni le module d'un autre chantier.
|
||
L'enregistrement est automatique (pkgutil) : routers montés sous `/api`, `models.py`/
|
||
`importers.py`/`ingest.py` importés au démarrage pour `create_all` et les registres.
|
||
3. `models.py` : style SQLAlchemy 2.0 typé (`Mapped`/`mapped_column`), héritage
|
||
`class X(TimestampMixin, Base)` ; noms de tables `snake_case` **pluriel** ; toute table métier
|
||
porte `user_id = mapped_column(ForeignKey("users.id"), index=True)` ; datetimes en
|
||
`DateTime(timezone=True)` et valeurs **UTC** ; colonnes « jour local » en `Date` ; poids kg,
|
||
longueurs cm ou m (distances), volumes ml, énergie kcal, durées secondes, argent en
|
||
**centimes** (`int`) ou `Numeric` pour les prix unitaires.
|
||
4. Table alimentée par import/ingestion → ajouter `SourceMixin` et les deux contraintes uniques :
|
||
`(user_id, source, external_id)` et `(user_id, content_hash)` (noms `uq_<table>_external`,
|
||
`uq_<table>_hash`). Agrégats journaliers → clé `(user_id, day, source)` + upsert
|
||
`ON CONFLICT DO UPDATE`.
|
||
5. `schemas.py` : Pydantic v2 ; suffixes `XxxCreate`, `XxxUpdate` (champs optionnels),
|
||
`XxxRead` (avec `model_config = ConfigDict(from_attributes=True)`) ; jamais de modèle ORM
|
||
retourné sans schéma `Read`.
|
||
6. `service.py` : logique métier ; signature `def fn(db: Session, user_id: int, ...)`. Les
|
||
routeurs restent minces (dépendances + appel service + schéma de réponse). Le service lève
|
||
les sous-classes d'`AppError` (`NotFoundError`, `ConflictError`, `DomainValidationError`…)
|
||
avec message **français** ; **interdit** de lever `HTTPException` dans un module.
|
||
7. Sécurité : tout endpoint (hors `auth` public et `healthz`) dépend de `get_current_user` et
|
||
filtre **systématiquement** par `user.id`. Endpoints d'ingestion : dépendance
|
||
`get_ingest_identity("ingest:<domain>")`.
|
||
8. Pagination : toute liste utilise `PageParams`/`Page[T]`/`paginate` de `core.pagination` —
|
||
pas de pagination maison. Tri via `?sort=` (préfixe `-` = desc), champs autorisés explicites.
|
||
9. Requêtes : `select()` SQLAlchemy 2.0 uniquement (pas de `session.query`). Le routeur/service
|
||
ne fait pas de `commit` partiel par ligne ; un `commit` par opération logique (le `get_db`
|
||
fournit la session, le service commit).
|
||
|
||
### C3. Ajouter un importeur de fichier
|
||
|
||
1. Dans `importers.py` du module métier concerné, sous-classer `BaseImporter`
|
||
(`app.core.importing.base`) et décorer avec `@register_importer`.
|
||
2. Renseigner `id` (snake_case unique, suffixe format : `_csv`, `_ofx`), `label` (français,
|
||
affiché dans l'UI), `domain`, `accepted_extensions`.
|
||
3. `sniff()` ne lève jamais et reste bon marché (extension + en-têtes dans les 4096 premiers
|
||
octets). `parse()` gère les encodages `utf-8-sig` puis `cp1252` et les CSV `;` à décimale
|
||
virgule ; il produit des `NormalizedRecord` en **unités canoniques** avec `external_id` si la
|
||
source en fournit un, sinon `dedupe_fields` pertinents (ex : `("posted_at", "amount_cents",
|
||
"label_raw")`).
|
||
4. `upsert()` retourne `INSERTED` / `UPDATED` / `DUPLICATE` — la détection de doublon se fait par
|
||
lookup sur les contraintes du C2.4 (pas par try/except d'IntegrityError en boucle).
|
||
5. Ne pas créer d'endpoint d'upload : `POST /api/imports` du module `imports` sert toutes les
|
||
sources.
|
||
|
||
### C4. Ajouter un handler d'ingestion JSON
|
||
|
||
Dans `ingest.py` du module : sous-classer `BaseIngestHandler`, décorer
|
||
`@register_ingest_handler`, déclarer `domain` et `record_types`, implémenter
|
||
`apply()` (mêmes règles de dédup que C3.4). Le endpoint `POST /api/ingest/{domain}` existe déjà ;
|
||
il exige le scope `ingest:<domain>` (ou `ingest:*`) pour les clés d'appareil.
|
||
|
||
### C5. Créer un module frontend
|
||
|
||
1. Créer `apps/web/src/modules/<name>/` avec `index.ts` dont l'**export default** est un
|
||
`ModuleManifest` (`src/types/module.ts`) : `id` = nom du dossier (anglais), `title` français,
|
||
`order` réservé (home 0, health 10, vape 20, finance 30, imports 80, settings 90 ; nouveaux
|
||
modules : dizaine libre), `routes` (chemins **absolus**, slugs **français** :
|
||
`/sante/...`, `/vape/...`, `/finances/...`), `nav` (labels français + icône `lucide-react`).
|
||
2. **Ne jamais modifier** `router.tsx`, `modules.ts`, `Sidebar.tsx` : la découverte
|
||
`import.meta.glob` est automatique. Ajouter une entrée de nav = ajouter un élément au tableau
|
||
`nav` du manifeste de son module.
|
||
3. Fichiers standard du module : `api.ts` (hooks React Query + types TS des schémas API),
|
||
`strings.ts` (chaînes françaises du module, export d'un objet constant — pas de français en
|
||
dur éparpillé dans le JSX pour les libellés réutilisés), `pages/`, `components/`.
|
||
4. Données : **toujours** via les hooks React Query de `api.ts` du module, qui appellent le
|
||
wrapper `api()` de `src/lib/api.ts` (jamais `fetch` direct). Clés de requête
|
||
`[moduleId, resource, params]` ; toute mutation invalide `[moduleId, resource]`.
|
||
5. Graphiques : exclusivement via `<EChart option={...}/>` (`src/components/charts/EChart.tsx`)
|
||
et le thème `lifetrack-dark` ; pas d'accès direct à `echarts.init` dans les pages ; formats
|
||
d'axes/tooltips via `src/lib/format.ts`.
|
||
6. UI : composants partagés de `src/components/ui/` d'abord ; classes Tailwind (palette sombre du
|
||
§7.5) ; pas de CSS externe, pas de CDN, pas de nouvelle dépendance sans l'ajouter à
|
||
`package.json` du repo.
|
||
7. Pages : nom `XxxPage.tsx`, chargées via `React.lazy` dans le manifeste ; états
|
||
vide/chargement/erreur systématiques (`EmptyState`, `Spinner`, message d'`ApiError.message`).
|
||
|
||
### C6. API — rappels contractuels
|
||
|
||
- Préfixe `/api/<module>` ; ressources au pluriel anglais ; `GET` liste paginée
|
||
(`Page[T]`), `POST` création (`201`), `PATCH` partiel, `PUT` singleton, `DELETE` → `204`.
|
||
- Forme d'erreur unique `{"error": {"code", "message", "details"}}` — `code` snake_case stable,
|
||
`message` en français.
|
||
- Datetimes : UTC ISO 8601 (`Z`) ; jours locaux : `YYYY-MM-DD` ; agrégations journalières :
|
||
paramètre `?tz=` (défaut `Europe/Paris`).
|
||
- Filtres temporels : `?from=`/`?to=` inclusifs.
|
||
|
||
### C7. Qualité
|
||
|
||
- Python : `ruff` (lint + format), type hints partout, pas d'import inutilisé ; tests `pytest`
|
||
dans `apps/api/app/tests/` (au minimum : contrat du routeur du module + dédup des importeurs).
|
||
- TypeScript : `strict: true`, pas de `any` non justifié ; build `npm run build` sans erreur.
|
||
- Aucune dépendance réseau à l'exécution côté web (fonts/icônes/librairies embarquées).
|
||
- Secrets uniquement via variables d'environnement ; rien de sensible commité (`.env` est
|
||
git-ignoré, `.env.example` documente).
|
||
|
||
---
|
||
|
||
*Fin du document — version 1.0, 2026-08-13.*
|