diff --git a/.env.example b/.env.example index 6e309d3..32a93d9 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,13 @@ -DB_PASSWORD=change-me-strong-password -SECRET_KEY=change-me-jwt-secret-key -STRAVA_CLIENT_ID= -STRAVA_CLIENT_SECRET= -POLAR_CLIENT_ID= -POLAR_CLIENT_SECRET= +# === REQUIRED (generated automatically by setup.sh) === +SECRET_KEY= + +# === APP URL (change for production) === +BASE_URL=http://localhost + +# === DATABASE (defaults work out of the box) === +DB_USER=fitness +DB_NAME=fitness_rewards + +# Everything else — Strava, Polar, Garmin, Fitbit, Telegram, +# USDA, Groq, etc. — is configured through the app UI or your AI agent. +# No container restart needed. diff --git a/.gitignore b/.gitignore index 6801dc4..cc1fa53 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ node_modules/ dist/ .DS_Store *.log + +# Excluded from open-source (scraped third-party content) +content/ diff --git a/backend/Dockerfile b/backend/Dockerfile index 1391ef3..db8324a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,20 +1,7 @@ FROM python:3.12-slim - WORKDIR /app - RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* - COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt - COPY . . - -ENV DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards -ENV SECRET_KEY=8915f64daf616b1b1ea5cdc8242ec924adf263c045bef378d9cb880787ba4386 -ENV STRAVA_CLIENT_ID=211907 -ENV STRAVA_CLIENT_SECRET=d1e48251327a3760acd6dcf4129ec59350dc6b71 -ENV POLAR_CLIENT_ID=13c4d547-4056-4b55-8412-a1cce84e887e -ENV POLAR_CLIENT_SECRET=29890119-2267-4a72-8888-e67ab0c0bf60 -ENV BASE_URL=https://fitness.littlefake.com - CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/config.py b/backend/app/config.py index 5106b5b..4ffe6be 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -29,7 +29,7 @@ class Settings(BaseSettings): telegram_chat_id: str = "" # App - base_url: str = "https://fitness.littlefake.com" + base_url: str = "http://localhost" timezone: str = "Asia/Bangkok" model_config = {"env_file": ".env", "extra": "ignore"} diff --git a/backend/app/main.py b/backend/app/main.py index b8474b5..070ffcf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -137,6 +137,8 @@ async def run_migrations(): """)) # v3: Seed keto point rules (fast_completed, keto_day, meal_logged) + # NOTE: Keto rules below are from v2. v3+ migrations (is_admin, integration_configs, + # meal plans) are added after this block. await conn.execute(text(""" DO $$ BEGIN INSERT INTO point_rules (id, user_id, category, points, unit, is_active) @@ -157,6 +159,118 @@ async def run_migrations(): END $$; """)) + # v4: Add is_admin column to users + await conn.execute(text(""" + DO $$ BEGIN + ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE; + EXCEPTION WHEN undefined_table THEN NULL; + END $$; + """)) + + # v4.1: Grant admin to first registered user if no admin exists + await conn.execute(text(""" + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM users WHERE is_admin = TRUE) THEN + UPDATE users SET is_admin = TRUE + WHERE id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1); + END IF; + EXCEPTION WHEN undefined_table THEN NULL; + WHEN undefined_column THEN NULL; + END $$; + """)) + + # v5: Create integration_configs table + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS integration_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + provider VARCHAR(50) NOT NULL, + config_key VARCHAR(100) NOT NULL, + config_value TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, provider, config_key) + ); + """)) + + # v6: Create meal plan tables + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS meal_plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + name VARCHAR(200) NOT NULL, + diet_type VARCHAR(50), + daily_calories INTEGER, + daily_protein_g INTEGER, + daily_carbs_g INTEGER, + daily_fat_g INTEGER, + restrictions JSONB DEFAULT '[]', + duration_days INTEGER NOT NULL, + start_date DATE NOT NULL, + status VARCHAR(20) DEFAULT 'active', + created_at TIMESTAMPTZ DEFAULT NOW() + ); + """)) + + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS meal_plan_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + plan_id UUID NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE, + day_number INTEGER NOT NULL, + meal_type VARCHAR(20) NOT NULL, + food_name VARCHAR(300) NOT NULL, + description TEXT, + calories INTEGER, + protein_g DECIMAL(6,1), + carbs_g DECIMAL(6,1), + fat_g DECIMAL(6,1), + fiber_g DECIMAL(6,1), + serving_size VARCHAR(100), + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() + ); + """)) + + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS meal_compliance ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + plan_id UUID NOT NULL REFERENCES meal_plans(id), + plan_item_id UUID, + compliance_date DATE NOT NULL, + status VARCHAR(20) NOT NULL, + substitution TEXT, + food_log_id UUID, + created_at TIMESTAMPTZ DEFAULT NOW() + ); + """)) + + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS idx_meal_plan_items_day ON meal_plan_items(plan_id, day_number); + CREATE INDEX IF NOT EXISTS idx_meal_compliance_date ON meal_compliance(user_id, compliance_date); + """)) + + # v7: Seed meal plan point rules + await conn.execute(text(""" + DO $$ BEGIN + INSERT INTO point_rules (id, user_id, category, points, unit, is_active) + SELECT gen_random_uuid(), u.id, 'meal_plan_followed', 40, 'per_event', TRUE + FROM users u + WHERE NOT EXISTS ( + SELECT 1 FROM point_rules pr + WHERE pr.user_id = u.id AND pr.category = 'meal_plan_followed' + ); + INSERT INTO point_rules (id, user_id, category, points, unit, is_active) + SELECT gen_random_uuid(), u.id, 'meal_plan_partial', 20, 'per_event', TRUE + FROM users u + WHERE NOT EXISTS ( + SELECT 1 FROM point_rules pr + WHERE pr.user_id = u.id AND pr.category = 'meal_plan_partial' + ); + EXCEPTION WHEN undefined_table THEN NULL; + END $$; + """)) + async def seed_resources(): """Seed the resource library with curated fitness programs.""" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f3fb7bf..104eebb 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -19,6 +19,7 @@ class User(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) is_active: Mapped[bool] = mapped_column(Boolean, default=True) timezone: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok") + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) # Relationships profile: Mapped["UserProfile"] = relationship(back_populates="user", uselist=False, lazy="selectin") diff --git a/backend/app/routers/support.py b/backend/app/routers/support.py index 9479123..c243602 100644 --- a/backend/app/routers/support.py +++ b/backend/app/routers/support.py @@ -24,7 +24,7 @@ from app.config import settings logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/support", tags=["support"]) -ADMIN_USERNAME = "scot" +# Admin check uses is_admin column on User model (first registered user gets admin=True) MAX_MESSAGES_PER_DAY = 10 @@ -105,7 +105,7 @@ def format_telegram_message(user_name: str, message: str, context: dict) -> str: lines.append(f"Last workout: {last}") lines.append(f"\nšŸ’¬ \"{message}\"") - lines.append(f"\nšŸ‘‰ https://fitness.littlefake.com/support/admin") + lines.append(f"\nšŸ‘‰ {settings.base_url}/support/admin") return "\n".join(lines) diff --git a/backend/app/services/crypto.py b/backend/app/services/crypto.py new file mode 100644 index 0000000..f326715 --- /dev/null +++ b/backend/app/services/crypto.py @@ -0,0 +1,42 @@ +"""Fernet encryption for integration credentials. + +Derives a Fernet-compatible key from the app SECRET_KEY using HKDF. +The SECRET_KEY can be any format (hex string, random bytes, passphrase). +""" +from __future__ import annotations + +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +import base64 + +_fernet_instance: Fernet | None = None + + +def _derive_fernet_key(secret_key: str) -> bytes: + """Derive a Fernet-compatible key from the app SECRET_KEY.""" + hkdf = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=b"diligence-integration-encryption", + info=b"fernet-key", + ) + raw = hkdf.derive(secret_key.encode()) + return base64.urlsafe_b64encode(raw) + + +def get_fernet(secret_key: str) -> Fernet: + global _fernet_instance + if _fernet_instance is None: + _fernet_instance = Fernet(_derive_fernet_key(secret_key)) + return _fernet_instance + + +def encrypt_value(secret_key: str, plaintext: str) -> str: + """Encrypt a plaintext string. Returns base64-encoded ciphertext.""" + return get_fernet(secret_key).encrypt(plaintext.encode()).decode() + + +def decrypt_value(secret_key: str, ciphertext: str) -> str: + """Decrypt a ciphertext string. Returns plaintext.""" + return get_fernet(secret_key).decrypt(ciphertext.encode()).decode() diff --git a/backend/app/services/food_lookup.py b/backend/app/services/food_lookup.py index 5beade0..e499945 100644 --- a/backend/app/services/food_lookup.py +++ b/backend/app/services/food_lookup.py @@ -5,7 +5,7 @@ import httpx from app.schemas.food import FoodSearchResult OFF_BASE = "https://world.openfoodfacts.org" -USER_AGENT = "FitnessRewards/1.0 (fitness.littlefake.com)" +USER_AGENT = "Diligence/1.0" async def lookup_barcode(barcode: str) -> FoodSearchResult | None: diff --git a/docker-compose.yml b/docker-compose.yml index 43c951f..ed1eb51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,17 +15,9 @@ services: backend: build: ./backend restart: unless-stopped + env_file: .env environment: - - DATABASE_URL=postgresql+asyncpg://fitness@fitness-db:5432/fitness_rewards - - SECRET_KEY=8915f64daf616b1b1ea5cdc8242ec924adf263c045bef378d9cb880787ba4386 - - STRAVA_CLIENT_ID=211907 - - STRAVA_CLIENT_SECRET=d1e48251327a3760acd6dcf4129ec59350dc6b71 - - POLAR_CLIENT_ID=13c4d547-4056-4b55-8412-a1cce84e887e - - POLAR_CLIENT_SECRET=29890119-2267-4a72-8888-e67ab0c0bf60 - - GROQ_API_KEY=${GROQ_API_KEY} - - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} - - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID} - - BASE_URL=https://fitness.littlefake.com + - BASE_URL=${BASE_URL:-http://localhost} depends_on: fitness-db: condition: service_healthy @@ -36,21 +28,31 @@ services: retries: 10 start_period: 30s + mcp-connector: + build: ./mcp-connector + restart: unless-stopped + environment: + - FITNESS_API_URL=http://backend:8000 + depends_on: + backend: + condition: service_healthy + ports: + - "3001:3001" + fitness-db: image: postgres:16-alpine restart: unless-stopped environment: - - POSTGRES_USER=fitness - - POSTGRES_DB=fitness_rewards + - POSTGRES_USER=${DB_USER:-fitness} + - POSTGRES_DB=${DB_NAME:-fitness_rewards} - POSTGRES_HOST_AUTH_METHOD=trust volumes: - fitness_db_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U fitness -d fitness_rewards"] + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fitness}"] interval: 5s timeout: 5s retries: 5 - start_period: 10s volumes: fitness_db_data: diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..a7bfbac --- /dev/null +++ b/setup.sh @@ -0,0 +1,19 @@ +#!/bin/bash +echo "šŸ’Ŗ Diligence — Setup" + +if [ -f .env ]; then + echo ".env already exists. Delete it first to regenerate." + exit 1 +fi + +SECRET=$(openssl rand -hex 32) +cp .env.example .env +sed -i "s/^SECRET_KEY=.*/SECRET_KEY=${SECRET}/" .env + +echo "āœ… .env created with random SECRET_KEY" +echo "" +echo "Next steps:" +echo " docker compose up -d" +echo " Open http://localhost" +echo " Register your account" +echo " Configure integrations via Settings or your AI agent"