feat: evolve MetaBloom into full product foundation
@@ -0,0 +1,28 @@
|
||||
name: MetaBloom CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
app:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run build:web
|
||||
api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- run: pip install -r server/requirements-dev.txt
|
||||
- run: pytest -q server/tests
|
||||
@@ -1,33 +1,38 @@
|
||||
# MetaBloom
|
||||
|
||||
Compagnon métabolique moderne, francophone et sans culpabilisation pour aider les personnes concernées par le surpoids, le prédiabète ou le diabète de type 2 à suivre leurs habitudes quotidiennes.
|
||||
MetaBloom est un compagnon métabolique mobile-first, francophone et non culpabilisant pour les personnes concernées par le surpoids, le prédiabète ou le diabète de type 2. Une seule base Expo cible iOS, Android et le Web.
|
||||
|
||||
> **Statut : prototype éducatif et bien-être.** MetaBloom ne diagnostique aucune maladie, ne calcule aucun traitement et ne remplace pas un professionnel de santé. En cas d’urgence, contactez les services d’urgence.
|
||||
> **Statut : produit en développement, usage éducatif et bien-être uniquement.** MetaBloom ne diagnostique pas, n’interprète pas une mesure, ne calcule pas de dose et ne modifie jamais un traitement. En cas d’urgence, contactez les services d’urgence.
|
||||
|
||||
## Le produit
|
||||
## Fonctionnalités disponibles
|
||||
|
||||
Une seule application mobile-first pour iOS, Android et le web :
|
||||
- onboarding avec objectif, parcours et consentement informatif ;
|
||||
- parcours Prévention, Perte de poids, Diabète type 2 et GLP-1 ;
|
||||
- dashboard quotidien responsive avec données réellement dynamiques ;
|
||||
- journal enrichi : poids, glycémie facultative, eau, pas, sommeil, repas et humeur ;
|
||||
- validation des valeurs et gestion mg/dL/mmol/L ;
|
||||
- graphiques et moyennes calculés sur les sept derniers jours ;
|
||||
- coaching comportemental et micro-leçons sans recommandations thérapeutiques ;
|
||||
- préférences, rappels préparés, export CSV et effacement local ;
|
||||
- persistance hors ligne via AsyncStorage ;
|
||||
- backend FastAPI/PostgreSQL auto-hébergeable avec JWT, consentement, CRUD, export et suppression de compte ;
|
||||
- préparation UI pour scan de repas, Apple Santé et Health Connect.
|
||||
|
||||
- tableau de bord quotidien avec score souple et petites actions ;
|
||||
- journal rapide : poids, eau, pas et glycémie manuelle facultative ;
|
||||
- tendances sur 7 jours et encouragements non culpabilisants ;
|
||||
- préférences d’unités et informations de sécurité ;
|
||||
- persistance locale sur l’appareil, sans compte ni serveur pour ce MVP ;
|
||||
- interface responsive, accessible et utilisable au clavier.
|
||||
## Captures
|
||||
|
||||
## Pourquoi MetaBloom ?
|
||||
|
||||
Le marché combine trois familles : suivi nutritionnel (MyFitnessPal), coaching comportemental (Noom/WW) et journal du diabète (mySugr). MetaBloom se différencie comme **compagnon métabolique inclusif et francophone**, simple, calme, centré sur la prévention et la confidentialité. Il évite les objectifs extrêmes et la gamification punitive.
|
||||
Les captures mobiles sont disponibles dans [`screenshots/`](./screenshots/).
|
||||
|
||||
## Stack
|
||||
|
||||
- Expo SDK 57
|
||||
- React Native 0.86 + React 19
|
||||
- TypeScript
|
||||
- AsyncStorage pour les données locales
|
||||
- Expo Linear Gradient
|
||||
| Couche | Technologie |
|
||||
|---|---|
|
||||
| Application | Expo SDK 57, React Native 0.86, React 19, TypeScript |
|
||||
| Stockage local | AsyncStorage |
|
||||
| API | FastAPI, SQLAlchemy, Pydantic |
|
||||
| Données serveur | PostgreSQL en production, SQLite en développement |
|
||||
| Sécurité API | JWT expirant, Argon2, isolation par utilisateur |
|
||||
|
||||
## Démarrer
|
||||
## Démarrer l’application
|
||||
|
||||
Prérequis : Node.js 20+ et npm.
|
||||
|
||||
@@ -36,41 +41,68 @@ npm install
|
||||
npm run web
|
||||
```
|
||||
|
||||
Autres cibles :
|
||||
|
||||
```bash
|
||||
npm run android # appareil/émulateur Android ou Expo Go
|
||||
npm run ios # macOS + simulateur iOS, ou Expo Go
|
||||
npm run android
|
||||
npm run ios
|
||||
npm run typecheck
|
||||
npm run build:web
|
||||
```
|
||||
|
||||
Les cibles Android/iOS fonctionnent avec Expo Go pour ce prototype. HealthKit/Health Connect nécessiteront un development build natif et des comptes développeur Apple/Google.
|
||||
|
||||
## Démarrer l’API
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -r requirements-dev.txt
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Documentation OpenAPI : `http://localhost:8000/docs`. Voir [`server/README.md`](./server/README.md) pour PostgreSQL, Docker et les règles de sécurité.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
App.tsx Interface, navigation et état du MVP
|
||||
index.ts Point d’entrée Expo
|
||||
app.json Configuration multiplateforme
|
||||
assets/ Icônes et splash screen
|
||||
App.tsx Expérience Expo multiplateforme
|
||||
assets/ Icônes et splash screen
|
||||
server/app/ API, modèles, validation et sécurité
|
||||
server/tests/ Tests d’intégration API
|
||||
server/docker-compose... PostgreSQL + API auto-hébergeables
|
||||
screenshots/ Captures produit
|
||||
.gitea/workflows/ Contrôles CI
|
||||
```
|
||||
|
||||
Le MVP reste volontairement monolithique pour accélérer la validation produit. Une V2 séparera écrans, composants, domaine et accès aux données.
|
||||
L’application fonctionne actuellement en mode local-first. L’API est prête séparément ; la prochaine étape d’intégration est une file de synchronisation idempotente avec gestion explicite des conflits.
|
||||
|
||||
## Données et sécurité
|
||||
## Sécurité et réglementation
|
||||
|
||||
Les données de démonstration et saisies sont stockées exclusivement via AsyncStorage sur l’appareil. Aucun backend, tracker publicitaire ou transfert de données n’est inclus.
|
||||
- aucune donnée n’est envoyée par l’application tant que l’API n’est pas configurée ;
|
||||
- l’API impose un consentement avant l’enregistrement de données de santé ;
|
||||
- les mots de passe sont hachés avec Argon2 et les données isolées par utilisateur ;
|
||||
- les secrets d’exemple ne doivent jamais être utilisés en production ;
|
||||
- AsyncStorage n’est pas chiffré : il doit être remplacé par SQLite + SecureStore avant production ;
|
||||
- une AIPD/RGPD, une analyse HDS, un audit OWASP et une qualification MDR sont obligatoires avant usage réel ;
|
||||
- aucun conseil GLP-1, seuil glycémique ou traitement n’est produit par l’application.
|
||||
|
||||
Avant une mise en production réelle : analyse d’impact RGPD, consentement explicite, chiffrement, gestion de l’effacement/export, évaluation HDS, audit de sécurité, validation clinique et qualification au regard du règlement européen MDR. Les fonctions de diagnostic, prédiction d’hypoglycémie, dosage d’insuline ou adaptation thérapeutique sont explicitement hors périmètre.
|
||||
## État des fonctionnalités avancées
|
||||
|
||||
## Roadmap
|
||||
- **Backend/auth/export/suppression :** implémentés et isolés dans `server/`.
|
||||
- **Synchronisation multi-appareil :** architecture définie, connexion client/API restante.
|
||||
- **Notifications :** préférences fonctionnelles ; branchement natif restant.
|
||||
- **Scan alimentaire :** expérience préparée ; source nutritionnelle et confirmation utilisateur restantes.
|
||||
- **HealthKit/Health Connect :** préparation produit ; dépendances natives et autorisations stores restantes.
|
||||
- **IA :** volontairement non branchée avant gouvernance clinique et corpus validé.
|
||||
|
||||
1. Tests utilisateurs et accessibilité WCAG.
|
||||
2. Authentification et synchronisation chiffrée auto-hébergeable.
|
||||
3. Export PDF/CSV partageable avec consentement.
|
||||
4. Rappels locaux et intégrations HealthKit / Health Connect.
|
||||
5. Base alimentaire francophone et scan, après validation qualité.
|
||||
6. Parcours professionnels de santé et conformité réglementaire.
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run build:web
|
||||
cd server && pytest -q
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
MIT — voir `LICENSE`.
|
||||
MIT — voir [`LICENSE`](./LICENSE).
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
"name": "MetaBloom",
|
||||
"slug": "metabloom",
|
||||
"version": "1.0.0",
|
||||
"scheme": "metabloom",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"description": "Des habitudes bienveillantes pour un meilleur équilibre métabolique.",
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "agency.vicode.metabloom"
|
||||
},
|
||||
"android": {
|
||||
"package": "agency.vicode.metabloom",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
|
||||
|
Before Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 148 KiB |
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
tests
|
||||
*.db
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL=postgresql+psycopg://metabloom:change-me@localhost:5432/metabloom
|
||||
JWT_SECRET=generate-a-random-secret-of-at-least-32-characters
|
||||
ENVIRONMENT=production
|
||||
ALLOWED_ORIGINS=https://app.example.com
|
||||
POSTGRES_PASSWORD=change-me
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
RUN useradd --create-home appuser
|
||||
USER appuser
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# MetaBloom API
|
||||
|
||||
Backend FastAPI isolé pour synchroniser les profils et journaux MetaBloom. Il fournit JWT, Argon2, séparation stricte des données par utilisateur, consentement explicite, CRUD, export JSON et suppression de compte.
|
||||
|
||||
> MetaBloom est un outil de bien-être, pas un dispositif médical. L’API ne diagnostique pas, n’interprète pas les mesures et ne recommande aucun traitement. En cas de valeur inquiétante ou de symptômes, contacter un professionnel ou les urgences.
|
||||
|
||||
## Démarrage local
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python -m venv .venv && . .venv/bin/activate
|
||||
pip install -r requirements-dev.txt
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Documentation : `http://localhost:8000/docs`. SQLite est utilisé en développement. Pour PostgreSQL, renseigner `DATABASE_URL` d’après `.env.example`.
|
||||
|
||||
## Docker
|
||||
|
||||
Copier `.env.example` vers `.env`, remplacer **tous** les exemples de secrets, puis :
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.example.yml --env-file .env up --build
|
||||
```
|
||||
|
||||
Le port n’écoute que sur localhost ; placer un reverse proxy TLS devant en production.
|
||||
|
||||
## API
|
||||
|
||||
- `POST /auth/register`, `POST /auth/token`
|
||||
- `GET/PUT /profile`
|
||||
- `GET/POST /entries`, `PUT/DELETE /entries/{id}`
|
||||
- `GET /account/export`, `DELETE /account`
|
||||
- `GET /health`
|
||||
|
||||
Envoyer `Authorization: Bearer <token>`. La création d’entrées exige `consent_health_data=true`. Les limites de saisie préviennent les erreurs grossières mais ne constituent pas une validation médicale.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest -q
|
||||
```
|
||||
|
||||
## Avant production
|
||||
|
||||
- gérer les schémas avec Alembic plutôt que `create_all` ;
|
||||
- utiliser un gestionnaire de secrets, TLS, sauvegardes chiffrées, rotation et révocation des jetons ;
|
||||
- ajouter rate limiting, journal d’audit sans donnée médicale, MFA et tests de sécurité ;
|
||||
- effectuer AIPD/RGPD, définir conservation/export, contrats de sous-traitance et hébergement HDS si applicable ;
|
||||
- faire qualifier juridiquement la finalité et le statut MDR avant toute fonction clinique.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "sqlite:///./metabloom.db"
|
||||
jwt_secret: str = "change-me-in-production-at-least-32-characters"
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_minutes: int = 30
|
||||
allowed_origins: str = "http://localhost:8081,http://localhost:19006"
|
||||
environment: str = "development"
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
@property
|
||||
def origins(self) -> list[str]:
|
||||
return [item.strip() for item in self.allowed_origins.split(",") if item.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=connect_args)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import Depends, FastAPI, HTTPException, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from .config import get_settings
|
||||
from .db import Base, engine, get_db
|
||||
from .models import Entry, Profile, User
|
||||
from .schemas import EntryIn, EntryOut, ProfileIn, ProfileOut, Register, Token
|
||||
from .security import current_user, hash_password, make_token, verify_password
|
||||
|
||||
cfg = get_settings()
|
||||
if cfg.environment == "production" and (len(cfg.jwt_secret) < 32 or cfg.jwt_secret.startswith("change-me")):
|
||||
raise RuntimeError("Set a strong JWT_SECRET in production")
|
||||
|
||||
app = FastAPI(title="MetaBloom API", version="1.0.0", description="Wellness tracking API — not a medical device.")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=cfg.origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "medical_notice": "MetaBloom does not diagnose or replace medical care."}
|
||||
|
||||
|
||||
@app.post("/auth/register", response_model=Token, status_code=201)
|
||||
def register(data: Register, db: Session = Depends(get_db)):
|
||||
email = data.email.lower()
|
||||
if db.scalar(select(User).where(User.email == email)):
|
||||
raise HTTPException(409, "Email already registered")
|
||||
user = User(email=email, password_hash=hash_password(data.password))
|
||||
db.add(user); db.flush()
|
||||
db.add(Profile(user_id=user.id)); db.commit()
|
||||
return Token(access_token=make_token(user.id))
|
||||
|
||||
|
||||
@app.post("/auth/token", response_model=Token)
|
||||
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = db.scalar(select(User).where(User.email == form.username.lower()))
|
||||
if not user or not verify_password(form.password, user.password_hash):
|
||||
raise HTTPException(401, "Invalid credentials", headers={"WWW-Authenticate": "Bearer"})
|
||||
return Token(access_token=make_token(user.id))
|
||||
|
||||
|
||||
@app.get("/profile", response_model=ProfileOut)
|
||||
def get_profile(user: User = Depends(current_user)):
|
||||
return user.profile
|
||||
|
||||
|
||||
@app.put("/profile", response_model=ProfileOut)
|
||||
def put_profile(data: ProfileIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
profile = user.profile or Profile(user_id=user.id)
|
||||
for key, value in data.model_dump().items(): setattr(profile, key, value)
|
||||
db.add(profile); db.commit(); db.refresh(profile)
|
||||
return profile
|
||||
|
||||
|
||||
@app.get("/entries", response_model=list[EntryOut])
|
||||
def list_entries(kind: str | None = None, limit: int = 100, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
limit = min(max(limit, 1), 500)
|
||||
query = select(Entry).where(Entry.user_id == user.id)
|
||||
if kind: query = query.where(Entry.kind == kind)
|
||||
return db.scalars(query.order_by(Entry.recorded_at.desc()).limit(limit)).all()
|
||||
|
||||
|
||||
@app.post("/entries", response_model=EntryOut, status_code=201)
|
||||
def create_entry(data: EntryIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
if not user.profile or not user.profile.consent_health_data:
|
||||
raise HTTPException(403, "Health-data consent is required")
|
||||
entry = Entry(user_id=user.id, **data.model_dump(exclude_none=True))
|
||||
db.add(entry); db.commit(); db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def owned_entry(entry_id: str, user: User, db: Session) -> Entry:
|
||||
entry = db.scalar(select(Entry).where(Entry.id == entry_id, Entry.user_id == user.id))
|
||||
if not entry: raise HTTPException(404, "Entry not found")
|
||||
return entry
|
||||
|
||||
|
||||
@app.put("/entries/{entry_id}", response_model=EntryOut)
|
||||
def update_entry(entry_id: str, data: EntryIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
entry = owned_entry(entry_id, user, db)
|
||||
for key, value in data.model_dump(exclude_none=True).items(): setattr(entry, key, value)
|
||||
db.commit(); db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@app.delete("/entries/{entry_id}", status_code=204)
|
||||
def delete_entry(entry_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
db.delete(owned_entry(entry_id, user, db)); db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.get("/account/export")
|
||||
def export_account(user: User = Depends(current_user)):
|
||||
return {"exported_at": datetime.now(timezone.utc), "medical_notice": "Informational data only; consult a professional for medical interpretation.", "profile": ProfileOut.model_validate(user.profile), "entries": [EntryOut.model_validate(x) for x in user.entries]}
|
||||
|
||||
|
||||
@app.delete("/account", status_code=204)
|
||||
def delete_account(user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
db.delete(user); db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from sqlalchemy import Boolean, Date, DateTime, Enum, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from .db import Base
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class EntryKind(str, enum.Enum):
|
||||
weight = "weight"
|
||||
glucose = "glucose"
|
||||
water = "water"
|
||||
activity = "activity"
|
||||
sleep = "sleep"
|
||||
mood = "mood"
|
||||
meal = "meal"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
profile: Mapped["Profile | None"] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
entries: Mapped[list["Entry"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Profile(Base):
|
||||
__tablename__ = "profiles"
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(80))
|
||||
birth_date: Mapped[date | None] = mapped_column(Date)
|
||||
height_cm: Mapped[float | None] = mapped_column(Float)
|
||||
objective: Mapped[str | None] = mapped_column(String(120))
|
||||
health_context: Mapped[str | None] = mapped_column(Text)
|
||||
consent_health_data: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||
user: Mapped[User] = relationship(back_populates="profile")
|
||||
|
||||
|
||||
class Entry(Base):
|
||||
__tablename__ = "entries"
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
kind: Mapped[EntryKind] = mapped_column(Enum(EntryKind))
|
||||
value: Mapped[float] = mapped_column(Float)
|
||||
unit: Mapped[str] = mapped_column(String(24))
|
||||
note: Mapped[str | None] = mapped_column(String(500))
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
user: Mapped[User] = relationship(back_populates="entries")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from datetime import date, datetime
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
|
||||
from .models import EntryKind
|
||||
|
||||
|
||||
class Register(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=12, max_length=128)
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class ProfileIn(BaseModel):
|
||||
display_name: str | None = Field(None, max_length=80)
|
||||
birth_date: date | None = None
|
||||
height_cm: float | None = Field(None, ge=80, le=250)
|
||||
objective: str | None = Field(None, max_length=120)
|
||||
health_context: str | None = Field(None, max_length=1000)
|
||||
consent_health_data: bool = False
|
||||
|
||||
|
||||
class ProfileOut(ProfileIn):
|
||||
user_id: str
|
||||
updated_at: datetime
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
RANGES = {
|
||||
EntryKind.weight: (20, 500), EntryKind.glucose: (20, 600), EntryKind.water: (0, 15000),
|
||||
EntryKind.activity: (0, 1440), EntryKind.sleep: (0, 24), EntryKind.mood: (1, 5),
|
||||
EntryKind.meal: (0, 10000),
|
||||
}
|
||||
|
||||
|
||||
class EntryIn(BaseModel):
|
||||
kind: EntryKind
|
||||
value: float
|
||||
unit: str = Field(min_length=1, max_length=24)
|
||||
note: str | None = Field(None, max_length=500)
|
||||
recorded_at: datetime | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def sensible_range(self):
|
||||
if self.kind == EntryKind.glucose:
|
||||
glucose_ranges = {"mg/dL": (20, 600), "mmol/L": (1.1, 33.3)}
|
||||
if self.unit not in glucose_ranges:
|
||||
raise ValueError("glucose unit must be mg/dL or mmol/L")
|
||||
low, high = glucose_ranges[self.unit]
|
||||
else:
|
||||
low, high = RANGES[self.kind]
|
||||
if not low <= self.value <= high:
|
||||
raise ValueError(f"value must be between {low} and {high} for {self.kind.value}")
|
||||
return self
|
||||
|
||||
|
||||
class EntryOut(EntryIn):
|
||||
id: str
|
||||
user_id: str
|
||||
recorded_at: datetime
|
||||
created_at: datetime
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,43 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from .config import get_settings
|
||||
from .db import get_db
|
||||
from .models import User
|
||||
|
||||
hasher = PasswordHasher()
|
||||
oauth2 = OAuth2PasswordBearer(tokenUrl="/auth/token")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, digest: str) -> bool:
|
||||
try:
|
||||
return hasher.verify(digest, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
def make_token(user_id: str) -> str:
|
||||
cfg = get_settings()
|
||||
now = datetime.now(timezone.utc)
|
||||
return jwt.encode({"sub": user_id, "iat": now, "exp": now + timedelta(minutes=cfg.access_token_minutes)}, cfg.jwt_secret, algorithm=cfg.jwt_algorithm)
|
||||
|
||||
|
||||
def current_user(token: str = Depends(oauth2), db: Session = Depends(get_db)) -> User:
|
||||
cfg = get_settings()
|
||||
try:
|
||||
user_id = jwt.decode(token, cfg.jwt_secret, algorithms=[cfg.jwt_algorithm])["sub"]
|
||||
except (jwt.PyJWTError, KeyError):
|
||||
raise HTTPException(401, "Invalid or expired token", headers={"WWW-Authenticate": "Bearer"})
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(401, "Account no longer exists")
|
||||
return user
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://metabloom:${POSTGRES_PASSWORD}@db:5432/metabloom
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ENVIRONMENT: production
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:8081}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports: ["127.0.0.1:8000:8000"]
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: metabloom
|
||||
POSTGRES_USER: metabloom
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes: ["postgres_data:/var/lib/postgresql/data"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U metabloom"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
pytest==8.3.4
|
||||
httpx==0.28.1
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
psycopg[binary]==3.2.3
|
||||
pydantic-settings==2.7.0
|
||||
PyJWT==2.10.1
|
||||
argon2-cffi==23.1.0
|
||||
email-validator==2.2.0
|
||||
python-multipart==0.0.20
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
os.environ["DATABASE_URL"] = "sqlite:///./test_metabloom.db"
|
||||
os.environ["JWT_SECRET"] = "test-secret-that-is-long-enough-for-tests"
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from app.db import Base, engine
|
||||
from app.main import app
|
||||
|
||||
|
||||
def setup_function():
|
||||
Base.metadata.drop_all(engine); Base.metadata.create_all(engine)
|
||||
|
||||
|
||||
def auth(client):
|
||||
response = client.post("/auth/register", json={"email": "user@example.com", "password": "a-very-safe-password"})
|
||||
assert response.status_code == 201
|
||||
return {"Authorization": f"Bearer {response.json()['access_token']}"}
|
||||
|
||||
|
||||
def test_health_auth_crud_export_delete():
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/health").status_code == 200
|
||||
headers = auth(client)
|
||||
assert client.post("/entries", headers=headers, json={"kind": "weight", "value": 90, "unit": "kg"}).status_code == 403
|
||||
profile = client.put("/profile", headers=headers, json={"display_name": "Alex", "height_cm": 175, "consent_health_data": True})
|
||||
assert profile.status_code == 200
|
||||
created = client.post("/entries", headers=headers, json={"kind": "weight", "value": 90, "unit": "kg"})
|
||||
assert created.status_code == 201
|
||||
entry_id = created.json()["id"]
|
||||
assert len(client.get("/entries", headers=headers).json()) == 1
|
||||
assert client.put(f"/entries/{entry_id}", headers=headers, json={"kind": "weight", "value": 89, "unit": "kg"}).json()["value"] == 89
|
||||
assert client.get("/account/export", headers=headers).status_code == 200
|
||||
assert client.delete(f"/entries/{entry_id}", headers=headers).status_code == 204
|
||||
assert client.delete("/account", headers=headers).status_code == 204
|
||||
|
||||
|
||||
def test_validation_and_isolation():
|
||||
with TestClient(app) as client:
|
||||
headers = auth(client)
|
||||
client.put("/profile", headers=headers, json={"consent_health_data": True})
|
||||
assert client.post("/entries", headers=headers, json={"kind": "glucose", "value": 999, "unit": "mg/dL"}).status_code == 422
|
||||
assert client.post("/entries", headers=headers, json={"kind": "glucose", "value": 5.5, "unit": "mmol/L"}).status_code == 201
|
||||
assert client.post("/entries", headers=headers, json={"kind": "glucose", "value": 5.5, "unit": "mg/dL"}).status_code == 422
|
||||
entries = client.get("/entries", headers=headers).json()
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["unit"] == "mmol/L"
|
||||