diff --git a/backend/auth.py b/backend/auth.py index d170058..21b0042 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -318,9 +318,8 @@ def increment_feature_usage(profile_id: str, feature_id: str) -> None: ON CONFLICT (profile_id, feature_id) DO UPDATE SET usage_count = user_feature_usage.usage_count + 1, - reset_at = %s, updated = CURRENT_TIMESTAMP - """, (profile_id, feature_id, next_reset, next_reset)) + """, (profile_id, feature_id, next_reset)) conn.commit() diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 86fa6ad..8ff768d 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -11,7 +11,7 @@ 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, check_feature_access, increment_feature_usage +from auth import require_auth from models import ActivityEntry from routers.profiles import get_pid @@ -94,17 +94,6 @@ def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: di async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Import Apple Health workout CSV.""" pid = get_pid(x_profile_id) - - # Check feature access (v9c feature system) - access = check_feature_access(pid, 'csv_import') - if not access['allowed']: - if access['reason'] == 'feature_disabled': - raise HTTPException(403, "CSV-Import ist für dein Tier nicht verfügbar") - elif access['reason'] == 'limit_exceeded': - raise HTTPException(429, f"Monatliches Import-Limit erreicht ({access['limit']} Imports)") - else: - raise HTTPException(403, f"Zugriff verweigert: {access['reason']}") - raw = await file.read() try: text = raw.decode('utf-8') except: text = raw.decode('latin-1') @@ -145,8 +134,4 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional tf(row.get('Distanz (km)','')))) inserted+=1 except: skipped+=1 - - # Increment import usage counter (v9c feature system) - increment_feature_usage(pid, 'csv_import') - return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"} diff --git a/backend/routers/exportdata.py b/backend/routers/exportdata.py index b0269ce..02109ea 100644 --- a/backend/routers/exportdata.py +++ b/backend/routers/exportdata.py @@ -17,7 +17,7 @@ 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, check_feature_access, increment_feature_usage +from auth import require_auth from routers.profiles import get_pid router = APIRouter(prefix="/api/export", tags=["export"]) @@ -30,15 +30,13 @@ 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 feature access (v9c feature system) - access = check_feature_access(pid, 'data_export') - if not access['allowed']: - if access['reason'] == 'feature_disabled': - raise HTTPException(403, "Export ist für dein Tier nicht verfügbar") - elif access['reason'] == 'limit_exceeded': - raise HTTPException(429, f"Monatliches Export-Limit erreicht ({access['limit']} Exporte)") - else: - raise HTTPException(403, f"Zugriff verweigert: {access['reason']}") + # Check export permission + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,)) + prof = cur.fetchone() + if not prof or not prof['export_enabled']: + raise HTTPException(403, "Export ist für dieses Profil deaktiviert") # Build CSV output = io.StringIO() @@ -76,10 +74,6 @@ 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) - - # Increment export usage counter (v9c feature system) - increment_feature_usage(pid, 'data_export') - return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", @@ -92,15 +86,13 @@ 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 feature access (v9c feature system) - access = check_feature_access(pid, 'data_export') - if not access['allowed']: - if access['reason'] == 'feature_disabled': - raise HTTPException(403, "Export ist für dein Tier nicht verfügbar") - elif access['reason'] == 'limit_exceeded': - raise HTTPException(429, f"Monatliches Export-Limit erreicht ({access['limit']} Exporte)") - else: - raise HTTPException(403, f"Zugriff verweigert: {access['reason']}") + # Check export permission + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,)) + prof = cur.fetchone() + if not prof or not prof['export_enabled']: + raise HTTPException(403, "Export ist für dieses Profil deaktiviert") # Collect all data data = {} @@ -134,10 +126,6 @@ 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) - - # Increment export usage counter (v9c feature system) - increment_feature_usage(pid, 'data_export') - return Response( content=json_str, media_type="application/json", @@ -150,21 +138,13 @@ 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 feature access (v9c feature system) - access = check_feature_access(pid, 'data_export') - if not access['allowed']: - if access['reason'] == 'feature_disabled': - raise HTTPException(403, "Export ist für dein Tier nicht verfügbar") - elif access['reason'] == 'limit_exceeded': - raise HTTPException(429, f"Monatliches Export-Limit erreicht ({access['limit']} Exporte)") - else: - raise HTTPException(403, f"Zugriff verweigert: {access['reason']}") - - # Get profile + # Check export permission & get profile with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) prof = r2d(cur.fetchone()) + if not prof or not prof.get('export_enabled'): + raise HTTPException(403, "Export ist für dieses Profil deaktiviert") # Helper: CSV writer with UTF-8 BOM + semicolon def write_csv(zf, filename, rows, columns): @@ -317,10 +297,6 @@ Datumsformat: YYYY-MM-DD zip_buffer.seek(0) filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip" - - # Increment export usage counter (v9c feature system) - 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 f2ced57..bd78e46 100644 --- a/backend/routers/importdata.py +++ b/backend/routers/importdata.py @@ -16,7 +16,7 @@ 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, check_feature_access, increment_feature_usage +from auth import require_auth from routers.profiles import get_pid router = APIRouter(prefix="/api/import", tags=["import"]) @@ -41,16 +41,6 @@ async def import_zip( """ pid = get_pid(x_profile_id) - # Check feature access (v9c feature system) - access = check_feature_access(pid, 'csv_import') - if not access['allowed']: - if access['reason'] == 'feature_disabled': - raise HTTPException(403, "Import ist für dein Tier nicht verfügbar") - elif access['reason'] == 'limit_exceeded': - raise HTTPException(429, f"Monatliches Import-Limit erreicht ({access['limit']} Imports)") - else: - raise HTTPException(403, f"Zugriff verweigert: {access['reason']}") - # Read uploaded file content = await file.read() zip_buffer = io.BytesIO(content) @@ -264,9 +254,6 @@ async def import_zip( conn.rollback() raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}") - # Increment import usage counter (v9c feature system) - increment_feature_usage(pid, 'csv_import') - return { "ok": True, "message": "Import erfolgreich", diff --git a/backend/routers/insights.py b/backend/routers/insights.py index 50ecbe9..b325bf0 100644 --- a/backend/routers/insights.py +++ b/backend/routers/insights.py @@ -13,7 +13,7 @@ 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, check_feature_access, increment_feature_usage +from auth import require_auth, require_admin from routers.profiles import get_pid router = APIRouter(prefix="/api", tags=["insights"]) @@ -251,16 +251,7 @@ 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) - - # Check feature access (v9c feature system) - access = check_feature_access(pid, 'ai_calls') - if not access['allowed']: - if access['reason'] == 'feature_disabled': - raise HTTPException(403, "KI-Analysen sind für dein Tier nicht verfügbar") - elif access['reason'] == 'limit_exceeded': - raise HTTPException(429, f"Monatliches KI-Limit erreicht ({access['limit']} Analysen)") - else: - raise HTTPException(403, f"Zugriff verweigert: {access['reason']}") + check_ai_limit(pid) # Get prompt template with get_db() as conn: @@ -310,8 +301,7 @@ 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)) - # Increment usage counter (v9c feature system) - increment_feature_usage(pid, 'ai_calls') + inc_ai_usage(pid) return {"scope": slug, "content": content} @@ -319,16 +309,7 @@ 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) - - # Check feature access for pipeline (v9c feature system) - access = check_feature_access(pid, 'ai_pipeline') - if not access['allowed']: - if access['reason'] == 'feature_disabled': - raise HTTPException(403, "KI-Pipeline ist für dein Tier nicht verfügbar") - elif access['reason'] == 'limit_exceeded': - raise HTTPException(429, f"Monatliches Pipeline-Limit erreicht ({access['limit']} Pipelines)") - else: - raise HTTPException(403, f"Zugriff verweigert: {access['reason']}") + check_ai_limit(pid) data = _get_profile_data(pid) vars = _prepare_template_vars(data) @@ -457,8 +438,7 @@ 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,'gesamt',%s,CURRENT_TIMESTAMP)", (str(uuid.uuid4()), pid, final_content)) - # Increment pipeline usage counter (v9c feature system) - increment_feature_usage(pid, 'ai_pipeline') + inc_ai_usage(pid) return {"scope": "gesamt", "content": final_content, "stage1": stage1_results} diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index d50bf75..e12b4c0 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -12,7 +12,7 @@ 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, check_feature_access, increment_feature_usage +from auth import require_auth from routers.profiles import get_pid router = APIRouter(prefix="/api/nutrition", tags=["nutrition"]) @@ -30,17 +30,6 @@ 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) - - # Check feature access (v9c feature system) - access = check_feature_access(pid, 'csv_import') - if not access['allowed']: - if access['reason'] == 'feature_disabled': - raise HTTPException(403, "CSV-Import ist für dein Tier nicht verfügbar") - elif access['reason'] == 'limit_exceeded': - raise HTTPException(429, f"Monatliches Import-Limit erreicht ({access['limit']} Imports)") - else: - raise HTTPException(403, f"Zugriff verweigert: {access['reason']}") - raw = await file.read() try: text = raw.decode('utf-8') except: text = raw.decode('latin-1') @@ -76,10 +65,6 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona 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)) inserted+=1 - - # Increment import usage counter (v9c feature system) - increment_feature_usage(pid, 'csv_import') - return {"rows_parsed":count,"days_imported":inserted, "date_range":{"from":min(days) if days else None,"to":max(days) if days else None}} diff --git a/frontend/src/components/FeatureGate.jsx b/frontend/src/components/FeatureGate.jsx deleted file mode 100644 index d4594a2..0000000 --- a/frontend/src/components/FeatureGate.jsx +++ /dev/null @@ -1,80 +0,0 @@ -import { useFeatureAccess } from '../hooks/useFeatureAccess' - -/** - * Feature Gate Component - * - * Hides children if feature is not available. - * Optionally shows upgrade prompt or message. - * - * @param {string} feature - Feature slug (e.g. 'ai_calls', 'data_export') - * @param {ReactNode} children - Content to gate - * @param {ReactNode} fallback - Optional fallback when not allowed - * @param {boolean} showUpgradePrompt - Show upgrade message instead of hiding - */ -export function FeatureGate({ feature, children, fallback = null, showUpgradePrompt = false }) { - const { canUse, reason, loading, limit, remaining } = useFeatureAccess(feature) - - // While loading, show children optimistically (better UX) - if (loading) return <>{children} - - // If allowed, render children - if (canUse) return <>{children} - - // Not allowed - if (showUpgradePrompt) { - return ( -
-
- Feature nicht verfügbar -
-
- {reason === 'feature_disabled' && 'Dieses Feature ist in deinem Tier nicht enthalten.'} - {reason === 'limit_exceeded' && `Limit erreicht (${limit}). Upgrade für mehr Zugriff.`} -
-
- ) - } - - // Hide completely - return fallback -} - -/** - * Feature Badge - Shows usage counter - * - * @param {string} feature - Feature slug - * @param {string} label - Label text (optional) - */ -export function FeatureBadge({ feature, label }) { - const { limit, used, remaining, loading } = useFeatureAccess(feature) - - if (loading) return null - if (limit === null) return null // Unlimited - - const percentage = limit > 0 ? (used / limit) * 100 : 0 - const color = percentage > 90 ? 'var(--danger)' : percentage > 70 ? '#FFA726' : 'var(--accent)' - - return ( -
- {label && {label}:} - {used}/{limit} - {remaining !== null && remaining === 0 && ⚠️} -
- ) -} diff --git a/frontend/src/hooks/useFeatureAccess.js b/frontend/src/hooks/useFeatureAccess.js deleted file mode 100644 index 9e710dd..0000000 --- a/frontend/src/hooks/useFeatureAccess.js +++ /dev/null @@ -1,67 +0,0 @@ -import { useState, useEffect } from 'react' -import { api } from '../utils/api' - -/** - * Hook to check feature access and usage limits - * - * @param {string} featureSlug - Feature ID (e.g. 'ai_calls', 'data_export') - * @returns {{ - * canUse: boolean, - * limit: number|null, - * used: number, - * remaining: number|null, - * reason: string, - * loading: boolean, - * error: string, - * refresh: function - * }} - */ -export function useFeatureAccess(featureSlug) { - const [state, setState] = useState({ - canUse: true, // Optimistic default - limit: null, - used: 0, - remaining: null, - reason: 'unknown', - loading: true, - error: '' - }) - - const refresh = async () => { - if (!featureSlug) { - setState(prev => ({ ...prev, loading: false })) - return - } - - try { - setState(prev => ({ ...prev, loading: true, error: '' })) - const access = await api.checkFeatureAccess(featureSlug) - setState({ - canUse: access.allowed, - limit: access.limit, - used: access.used, - remaining: access.remaining, - reason: access.reason, - loading: false, - error: '' - }) - } catch (e) { - console.error(`Feature access check failed for ${featureSlug}:`, e) - setState({ - canUse: false, - limit: null, - used: 0, - remaining: null, - reason: 'error', - loading: false, - error: e.message - }) - } - } - - useEffect(() => { - refresh() - }, [featureSlug]) - - return { ...state, refresh } -} diff --git a/frontend/src/pages/Analysis.jsx b/frontend/src/pages/Analysis.jsx index 4b4c8ca..630bc1b 100644 --- a/frontend/src/pages/Analysis.jsx +++ b/frontend/src/pages/Analysis.jsx @@ -2,8 +2,6 @@ import { useState, useEffect } from 'react' import { Brain, Pencil, Trash2, ChevronDown, ChevronUp, Check, X } from 'lucide-react' import { api } from '../utils/api' import { useAuth } from '../context/AuthContext' -import { FeatureGate, FeatureBadge } from '../components/FeatureGate' -import { useFeatureAccess } from '../hooks/useFeatureAccess' import Markdown from '../utils/Markdown' import dayjs from 'dayjs' import 'dayjs/locale/de' @@ -229,39 +227,35 @@ export default function Analysis() { {/* Pipeline button - only if all sub-prompts are active */} {pipelineAvailable && ( - -
-
-
-
-
🔬 Mehrstufige Gesamtanalyse
- -
-
- 3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität), - dann Synthese + Zielabgleich. Detaillierteste Auswertung. -
- {allInsights.find(i=>i.scope==='pipeline') && ( -
- Letzte Analyse: {dayjs(allInsights.find(i=>i.scope==='pipeline').created).format('DD.MM.YYYY, HH:mm')} -
- )} +
+
+
+
🔬 Mehrstufige Gesamtanalyse
+
+ 3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität), + dann Synthese + Zielabgleich. Detaillierteste Auswertung.
- + {allInsights.find(i=>i.scope==='pipeline') && ( +
+ Letzte Analyse: {dayjs(allInsights.find(i=>i.scope==='pipeline').created).format('DD.MM.YYYY, HH:mm')} +
+ )}
- {pipelineLoading && ( -
- ⚡ Stufe 1: 3 parallele Analyse-Calls… dann Synthese… dann Zielabgleich -
- )} + + {!canUseAI &&
🔒 KI nicht freigeschaltet
}
- + {pipelineLoading && ( +
+ ⚡ Stufe 1: 3 parallele Analyse-Calls… dann Synthese… dann Zielabgleich +
+ )} +
)} {!canUseAI && ( @@ -277,13 +271,11 @@ export default function Analysis() {
)} - -

- Oder wähle eine Einzelanalyse: - -

+ {canUseAI &&

+ Oder wähle eine Einzelanalyse: +

} - {activePrompts.map(p => { + {activePrompts.map(p => { // Show latest existing insight for this prompt const existing = allInsights.find(i=>i.scope===p.slug) return ( @@ -313,11 +305,10 @@ export default function Analysis() { )}
) - })} - {activePrompts.length===0 && ( -

Keine aktiven Prompts. Aktiviere im Tab "Prompts".

- )} -
+ })} + {activePrompts.length===0 && ( +

Keine aktiven Prompts. Aktiviere im Tab "Prompts".

+ )} )} diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 2d2cf12..b5a0de0 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -1,9 +1,7 @@ import { useState } from 'react' -import { Link } from 'react-router-dom' import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react' import { useProfile } from '../context/ProfileContext' import { useAuth } from '../context/AuthContext' -import { FeatureGate, FeatureBadge } from '../components/FeatureGate' import { Avatar } from './ProfileSelect' import { api } from '../utils/api' import AdminPanel from './AdminPanel' @@ -236,16 +234,6 @@ export default function SettingsPage() {

Einstellungen

- {/* Subscription */} -
-
Mein Abo
- - - -
- {/* Profile list */}
Profile ({profiles.length})
@@ -355,17 +343,20 @@ export default function SettingsPage() { )} {/* Export */} - -
-
- Daten exportieren - -
-

- Exportiert alle Daten von {activeProfile?.name}: - Gewicht, Umfänge, Caliper, Ernährung, Aktivität und KI-Auswertungen. -

-
+
+
Daten exportieren
+

+ Exportiert alle Daten von {activeProfile?.name}: + Gewicht, Umfänge, Caliper, Ernährung, Aktivität und KI-Auswertungen. +

+
+ {!canExport && ( +
+ 🔒 Export ist für dein Profil nicht freigeschaltet. Bitte den Admin kontaktieren. +
+ )} + {canExport && <> -
-

- Der ZIP-Export enthält separate Dateien für Excel und eine lesbare KI-Auswertungsdatei. -

+ }
- +

+ Der ZIP-Export enthält separate Dateien für Excel und eine lesbare KI-Auswertungsdatei. +

+
{/* Import */} - -
-
- Backup importieren - -
-

- Importiere einen ZIP-Export zurück in {activeProfile?.name}. - Vorhandene Einträge werden nicht überschrieben. -

-
-
{saved && (
req('/access-grants',json(d)), updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)), revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}), - - // Feature Access (v9c) - checkFeatureAccess: (featureSlug) => req(`/features/${featureSlug}/check-access`), }