From ddcd2f43504a4b208046bff9a007a48ca498075e Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 20 Mar 2026 21:59:33 +0100 Subject: [PATCH] feat: v9c Phase 2 - Backend Non-Blocking Logging (12 Endpoints) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHASE 2: Backend Non-Blocking Logging - KOMPLETT Instrumentierte Endpoints (12): - Data: weight, circumference, caliper, nutrition, activity, photos (6) - AI: insights/run/{slug}, insights/pipeline (2) - Export: csv, json, zip (3) - Import: zip (1) Pattern implementiert: - check_feature_access() VOR Operation (non-blocking) - [FEATURE-LIMIT] Logging wenn Limit überschritten - increment_feature_usage() NACH Operation - Alte Permission-Checks bleiben aktiv Features geprüft: - weight_entries, circumference_entries, caliper_entries - nutrition_entries, activity_entries, photos - ai_calls, ai_pipeline - data_export, data_import Monitoring: 1-2 Wochen Log-Only-Phase Logs zeigen: Wie oft würde blockiert werden? Nächste Phase: Frontend Display (Usage-Counter) Phase 1 (Cleanup) + Phase 2 (Logging) vollständig! Co-Authored-By: Claude Opus 4.6 --- backend/routers/activity.py | 17 +++++++++++- backend/routers/caliper.py | 21 +++++++++++++- backend/routers/circumference.py | 21 +++++++++++++- backend/routers/exportdata.py | 47 +++++++++++++++++++++++++++++--- backend/routers/importdata.py | 16 ++++++++++- backend/routers/insights.py | 45 +++++++++++++++++++++++++++++- backend/routers/nutrition.py | 28 +++++++++++++++++-- backend/routers/photos.py | 17 +++++++++++- backend/routers/weight.py | 22 ++++++++++++++- 9 files changed, 220 insertions(+), 14 deletions(-) diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 8ff768d..5a1c012 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -6,16 +6,18 @@ Handles workout/activity logging, statistics, and Apple Health CSV import. import csv import io import uuid +import logging from typing import Optional from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends from db import get_db, get_cursor, r2d -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from models import ActivityEntry from routers.profiles import get_pid router = APIRouter(prefix="/api/activity", tags=["activity"]) +logger = logging.getLogger(__name__) @router.get("") @@ -33,6 +35,15 @@ def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=Non def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Create new activity entry.""" pid = get_pid(x_profile_id) + + # Phase 2: Check feature access (non-blocking, log only) + access = check_feature_access(pid, 'activity_entries') + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + eid = str(uuid.uuid4()) d = e.model_dump() with get_db() as conn: @@ -44,6 +55,10 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default (eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'], d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'], d['rpe'],d['source'],d['notes'])) + + # Phase 2: Increment usage counter (always for new entries) + increment_feature_usage(pid, 'activity_entries') + return {"id":eid,"date":e.date} diff --git a/backend/routers/caliper.py b/backend/routers/caliper.py index b217a4c..72037eb 100644 --- a/backend/routers/caliper.py +++ b/backend/routers/caliper.py @@ -4,16 +4,18 @@ Caliper/Skinfold Tracking Endpoints for Mitai Jinkendo Handles body fat measurements via skinfold caliper (4 methods supported). """ import uuid +import logging from typing import Optional from fastapi import APIRouter, Header, Depends from db import get_db, get_cursor, r2d -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from models import CaliperEntry from routers.profiles import get_pid router = APIRouter(prefix="/api/caliper", tags=["caliper"]) +logger = logging.getLogger(__name__) @router.get("") @@ -31,17 +33,30 @@ def list_caliper(limit: int=100, x_profile_id: Optional[str]=Header(default=None def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Create or update caliper entry (upsert by date).""" pid = get_pid(x_profile_id) + + # Phase 2: Check feature access (non-blocking, log only) + access = check_feature_access(pid, 'caliper_entries') + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"caliper_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date)) ex = cur.fetchone() d = e.model_dump() + is_new_entry = not ex + if ex: + # UPDATE existing entry eid = ex['id'] sets = ', '.join(f"{k}=%s" for k in d if k!='date') cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s", [v for k,v in d.items() if k!='date']+[eid]) else: + # INSERT new entry eid = str(uuid.uuid4()) cur.execute("""INSERT INTO caliper_log (id,profile_id,date,sf_method,sf_chest,sf_axilla,sf_triceps,sf_subscap,sf_suprailiac, @@ -50,6 +65,10 @@ def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=N (eid,pid,d['date'],d['sf_method'],d['sf_chest'],d['sf_axilla'],d['sf_triceps'], d['sf_subscap'],d['sf_suprailiac'],d['sf_abdomen'],d['sf_thigh'],d['sf_calf_med'], d['sf_lowerback'],d['sf_biceps'],d['body_fat_pct'],d['lean_mass'],d['fat_mass'],d['notes'])) + + # Phase 2: Increment usage counter (only for new entries) + increment_feature_usage(pid, 'caliper_entries') + return {"id":eid,"date":e.date} diff --git a/backend/routers/circumference.py b/backend/routers/circumference.py index feba22c..90beea9 100644 --- a/backend/routers/circumference.py +++ b/backend/routers/circumference.py @@ -4,16 +4,18 @@ Circumference Tracking Endpoints for Mitai Jinkendo Handles body circumference measurements (8 measurement points). """ import uuid +import logging from typing import Optional from fastapi import APIRouter, Header, Depends from db import get_db, get_cursor, r2d -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from models import CircumferenceEntry from routers.profiles import get_pid router = APIRouter(prefix="/api/circumferences", tags=["circumference"]) +logger = logging.getLogger(__name__) @router.get("") @@ -31,23 +33,40 @@ def list_circs(limit: int=100, x_profile_id: Optional[str]=Header(default=None), def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Create or update circumference entry (upsert by date).""" pid = get_pid(x_profile_id) + + # Phase 2: Check feature access (non-blocking, log only) + access = check_feature_access(pid, 'circumference_entries') + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"circumference_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date)) ex = cur.fetchone() d = e.model_dump() + is_new_entry = not ex + if ex: + # UPDATE existing entry eid = ex['id'] sets = ', '.join(f"{k}=%s" for k in d if k!='date') cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s", [v for k,v in d.items() if k!='date']+[eid]) else: + # INSERT new entry eid = str(uuid.uuid4()) cur.execute("""INSERT INTO circumference_log (id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""", (eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'], d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id'])) + + # Phase 2: Increment usage counter (only for new entries) + increment_feature_usage(pid, 'circumference_entries') + return {"id":eid,"date":e.date} diff --git a/backend/routers/exportdata.py b/backend/routers/exportdata.py index 02109ea..47a1dd5 100644 --- a/backend/routers/exportdata.py +++ b/backend/routers/exportdata.py @@ -7,6 +7,7 @@ import os import csv import io import json +import logging import zipfile from pathlib import Path from typing import Optional @@ -17,10 +18,11 @@ from fastapi import APIRouter, HTTPException, Header, Depends from fastapi.responses import StreamingResponse, Response from db import get_db, get_cursor, r2d -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from routers.profiles import get_pid router = APIRouter(prefix="/api/export", tags=["export"]) +logger = logging.getLogger(__name__) PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) @@ -30,7 +32,16 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D """Export all data as CSV.""" pid = get_pid(x_profile_id) - # Check export permission + # Phase 2: Check feature access (non-blocking, log only) + access = check_feature_access(pid, 'data_export') + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + # NOTE: Phase 2 does NOT block - just logs! + + # Old permission check (keep for now) with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,)) @@ -74,6 +85,10 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"]) output.seek(0) + + # Phase 2: Increment usage counter + increment_feature_usage(pid, 'data_export') + return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", @@ -86,7 +101,15 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict= """Export all data as JSON.""" pid = get_pid(x_profile_id) - # Check export permission + # Phase 2: Check feature access (non-blocking, log only) + access = check_feature_access(pid, 'data_export') + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + + # Old permission check (keep for now) with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,)) @@ -126,6 +149,10 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict= return str(obj) json_str = json.dumps(data, indent=2, default=decimal_handler) + + # Phase 2: Increment usage counter + increment_feature_usage(pid, 'data_export') + return Response( content=json_str, media_type="application/json", @@ -138,7 +165,15 @@ def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=D """Export all data as ZIP (CSV + JSON + photos) per specification.""" pid = get_pid(x_profile_id) - # Check export permission & get profile + # Phase 2: Check feature access (non-blocking, log only) + access = check_feature_access(pid, 'data_export') + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + + # Old permission check & get profile with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) @@ -297,6 +332,10 @@ Datumsformat: YYYY-MM-DD zip_buffer.seek(0) filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip" + + # Phase 2: Increment usage counter + increment_feature_usage(pid, 'data_export') + return StreamingResponse( iter([zip_buffer.getvalue()]), media_type="application/zip", diff --git a/backend/routers/importdata.py b/backend/routers/importdata.py index bd78e46..1b572ab 100644 --- a/backend/routers/importdata.py +++ b/backend/routers/importdata.py @@ -8,6 +8,7 @@ import csv import io import json import uuid +import logging import zipfile from pathlib import Path from typing import Optional @@ -16,10 +17,11 @@ from datetime import datetime from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends from db import get_db, get_cursor -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from routers.profiles import get_pid router = APIRouter(prefix="/api/import", tags=["import"]) +logger = logging.getLogger(__name__) PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) @@ -41,6 +43,15 @@ async def import_zip( """ pid = get_pid(x_profile_id) + # Phase 2: Check feature access (non-blocking, log only) + access = check_feature_access(pid, 'data_import') + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"data_import {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + # NOTE: Phase 2 does NOT block - just logs! + # Read uploaded file content = await file.read() zip_buffer = io.BytesIO(content) @@ -254,6 +265,9 @@ async def import_zip( conn.rollback() raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}") + # Phase 2: Increment usage counter + increment_feature_usage(pid, 'data_import') + return { "ok": True, "message": "Import erfolgreich", diff --git a/backend/routers/insights.py b/backend/routers/insights.py index f624f35..f4f11e4 100644 --- a/backend/routers/insights.py +++ b/backend/routers/insights.py @@ -6,6 +6,7 @@ Handles AI analysis execution, prompt management, and usage tracking. import os import json import uuid +import logging import httpx from typing import Optional from datetime import datetime @@ -13,10 +14,11 @@ from datetime import datetime from fastapi import APIRouter, HTTPException, Header, Depends from db import get_db, get_cursor, r2d -from auth import require_auth, require_admin +from auth import require_auth, require_admin, check_feature_access, increment_feature_usage from routers.profiles import get_pid router = APIRouter(prefix="/api", tags=["insights"]) +logger = logging.getLogger(__name__) OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "") OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4") @@ -251,6 +253,17 @@ def delete_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=Non async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Run AI analysis with specified prompt template.""" pid = get_pid(x_profile_id) + + # Phase 2: Check feature access (non-blocking, log only) + access = check_feature_access(pid, 'ai_calls') + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"ai_calls {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + # NOTE: Phase 2 does NOT block - just logs! + + # Old check (keep for now, but will be replaced in Phase 4) check_ai_limit(pid) # Get prompt template @@ -300,7 +313,12 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", (str(uuid.uuid4()), pid, slug, content)) + # Phase 2: Increment new feature usage counter + increment_feature_usage(pid, 'ai_calls') + + # Old usage tracking (keep for now) inc_ai_usage(pid) + return {"scope": slug, "content": content} @@ -308,6 +326,25 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Run 3-stage pipeline analysis.""" pid = get_pid(x_profile_id) + + # Phase 2: Check pipeline feature access (boolean - enabled/disabled) + access_pipeline = check_feature_access(pid, 'ai_pipeline') + if not access_pipeline['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"ai_pipeline {access_pipeline['reason']}" + ) + # NOTE: Phase 2 does NOT block - just logs! + + # Also check ai_calls (pipeline uses API calls too) + access_calls = check_feature_access(pid, 'ai_calls') + if not access_calls['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"ai_calls {access_calls['reason']} (used: {access_calls['used']}, limit: {access_calls['limit']})" + ) + + # Old check (keep for now) check_ai_limit(pid) data = _get_profile_data(pid) @@ -436,7 +473,13 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'pipeline',%s,CURRENT_TIMESTAMP)", (str(uuid.uuid4()), pid, final_content)) + # Phase 2: Increment ai_calls usage (pipeline uses multiple API calls) + # Note: We increment once per pipeline run, not per individual call + increment_feature_usage(pid, 'ai_calls') + + # Old usage tracking (keep for now) inc_ai_usage(pid) + return {"scope": "pipeline", "content": final_content, "stage1": stage1_results} diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index e12b4c0..f6b89aa 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -6,16 +6,18 @@ Handles nutrition data, FDDB CSV import, correlations, and weekly aggregates. import csv import io import uuid +import logging from typing import Optional from datetime import datetime from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends from db import get_db, get_cursor, r2d -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from routers.profiles import get_pid router = APIRouter(prefix="/api/nutrition", tags=["nutrition"]) +logger = logging.getLogger(__name__) # ── Helper ──────────────────────────────────────────────────────────────────── @@ -30,6 +32,16 @@ def _pf(s): async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Import FDDB nutrition CSV.""" pid = get_pid(x_profile_id) + + # Phase 2: Check feature access (non-blocking, log only) + # Note: CSV import can create many entries - we check once before import + access = check_feature_access(pid, 'nutrition_entries') + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raw = await file.read() try: text = raw.decode('utf-8') except: text = raw.decode('latin-1') @@ -52,20 +64,30 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona days[iso]['protein_g'] += _pf(row.get('protein_g',0)) count+=1 inserted=0 + new_entries=0 with get_db() as conn: cur = get_cursor(conn) for iso,vals in days.items(): kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1) carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1) cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso)) - if cur.fetchone(): + is_new = not cur.fetchone() + if not is_new: + # UPDATE existing cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s", (kcal,prot,fat,carbs,pid,iso)) else: + # INSERT new cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)", (str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs)) + new_entries += 1 inserted+=1 - return {"rows_parsed":count,"days_imported":inserted, + + # Phase 2: Increment usage counter for each new entry created + for _ in range(new_entries): + increment_feature_usage(pid, 'nutrition_entries') + + return {"rows_parsed":count,"days_imported":inserted,"new_entries":new_entries, "date_range":{"from":min(days) if days else None,"to":max(days) if days else None}} diff --git a/backend/routers/photos.py b/backend/routers/photos.py index cb847ec..32778ac 100644 --- a/backend/routers/photos.py +++ b/backend/routers/photos.py @@ -5,6 +5,7 @@ Handles progress photo uploads and retrieval. """ import os import uuid +import logging from pathlib import Path from typing import Optional @@ -13,10 +14,11 @@ from fastapi.responses import FileResponse import aiofiles from db import get_db, get_cursor, r2d -from auth import require_auth, require_auth_flexible +from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage from routers.profiles import get_pid router = APIRouter(prefix="/api/photos", tags=["photos"]) +logger = logging.getLogger(__name__) PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) PHOTOS_DIR.mkdir(parents=True, exist_ok=True) @@ -27,6 +29,15 @@ async def upload_photo(file: UploadFile=File(...), date: str="", x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Upload progress photo.""" pid = get_pid(x_profile_id) + + # Phase 2: Check feature access (non-blocking, log only) + access = check_feature_access(pid, 'photos') + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"photos {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + fid = str(uuid.uuid4()) ext = Path(file.filename).suffix or '.jpg' path = PHOTOS_DIR / f"{fid}{ext}" @@ -35,6 +46,10 @@ async def upload_photo(file: UploadFile=File(...), date: str="", cur = get_cursor(conn) cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", (fid,pid,date,str(path))) + + # Phase 2: Increment usage counter + increment_feature_usage(pid, 'photos') + return {"id":fid,"date":date} diff --git a/backend/routers/weight.py b/backend/routers/weight.py index 0d66713..44ef971 100644 --- a/backend/routers/weight.py +++ b/backend/routers/weight.py @@ -4,16 +4,18 @@ Weight Tracking Endpoints for Mitai Jinkendo Handles weight log CRUD operations and statistics. """ import uuid +import logging from typing import Optional from fastapi import APIRouter, Header, Depends from db import get_db, get_cursor, r2d -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from models import WeightEntry from routers.profiles import get_pid router = APIRouter(prefix="/api/weight", tags=["weight"]) +logger = logging.getLogger(__name__) @router.get("") @@ -31,17 +33,35 @@ def list_weight(limit: int=365, x_profile_id: Optional[str]=Header(default=None) def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Create or update weight entry (upsert by date).""" pid = get_pid(x_profile_id) + + # Phase 2: Check feature access (non-blocking, log only) + access = check_feature_access(pid, 'weight_entries') + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} would be blocked: " + f"weight_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + # NOTE: Phase 2 does NOT block - just logs! + with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date)) ex = cur.fetchone() + is_new_entry = not ex + if ex: + # UPDATE existing entry cur.execute("UPDATE weight_log SET weight=%s,note=%s WHERE id=%s", (e.weight,e.note,ex['id'])) wid = ex['id'] else: + # INSERT new entry wid = str(uuid.uuid4()) cur.execute("INSERT INTO weight_log (id,profile_id,date,weight,note,created) VALUES (%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)", (wid,pid,e.date,e.weight,e.note)) + + # Phase 2: Increment usage counter (only for new entries) + increment_feature_usage(pid, 'weight_entries') + return {"id":wid,"date":e.date,"weight":e.weight}