From 1db780858bc3585b71f5d34fc877804f8387f1b9 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 18 Mar 2026 17:07:41 +0100 Subject: [PATCH] fix: align all API endpoints between frontend and backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed 11 critical endpoint mismatches found during codebase audit. **Renamed Endpoints (consistency):** - /api/ai/analyze/{slug} → /api/insights/run/{slug} - /api/ai/analyze-pipeline → /api/insights/pipeline - /api/auth/password-reset-request → /api/auth/forgot-password - /api/auth/password-reset-confirm → /api/auth/reset-password - /api/admin/test-email → /api/admin/email/test **Added Missing Endpoints:** - POST /api/auth/pin (change PIN/password for current user) - PUT /api/admin/profiles/{id}/permissions (set permissions) - PUT /api/admin/profiles/{id}/email (set email) - PUT /api/admin/profiles/{id}/pin (admin set PIN) - GET /api/admin/email/status (check SMTP config) - PUT /api/prompts/{id} (edit prompt templates, admin only) - GET /api/export/json (export all data as JSON) - GET /api/export/zip (export data + photos as ZIP) **Updated:** - Added imports: json, zipfile, Response - Fixed admin email test endpoint to accept dict body All frontend API calls now have matching backend implementations. Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 235 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 227 insertions(+), 8 deletions(-) diff --git a/backend/main.py b/backend/main.py index a097e54..4c09098 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,11 +1,11 @@ -import os, csv, io, uuid +import os, csv, io, uuid, json, zipfile from pathlib import Path from typing import Optional from datetime import datetime from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Query, Depends from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse, FileResponse +from fastapi.responses import StreamingResponse, FileResponse, Response from pydantic import BaseModel import aiofiles import bcrypt @@ -852,7 +852,7 @@ def _prepare_template_vars(data: dict) -> dict: return vars -@app.post("/api/ai/analyze/{slug}") +@app.post("/api/insights/run/{slug}") 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) @@ -909,7 +909,7 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa inc_ai_usage(pid) return {"scope": slug, "content": content} -@app.post("/api/ai/analyze-pipeline") +@app.post("/api/insights/pipeline") 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) @@ -1053,6 +1053,32 @@ def list_prompts(session: dict=Depends(require_auth)): cur.execute("SELECT * FROM ai_prompts WHERE active=true AND slug NOT LIKE 'pipeline_%' ORDER BY sort_order") return [r2d(r) for r in cur.fetchall()] +@app.put("/api/prompts/{prompt_id}") +def update_prompt(prompt_id: str, data: dict, session: dict=Depends(require_admin)): + """Update AI prompt template (admin only).""" + with get_db() as conn: + cur = get_cursor(conn) + updates = [] + values = [] + if 'name' in data: + updates.append('name=%s') + values.append(data['name']) + if 'description' in data: + updates.append('description=%s') + values.append(data['description']) + if 'template' in data: + updates.append('template=%s') + values.append(data['template']) + if 'active' in data: + updates.append('active=%s') + values.append(data['active']) + + if updates: + cur.execute(f"UPDATE ai_prompts SET {', '.join(updates)}, updated=CURRENT_TIMESTAMP WHERE id=%s", + values + [prompt_id]) + + return {"ok": True} + @app.get("/api/ai/usage") def get_ai_usage(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Get AI usage stats for current profile.""" @@ -1145,7 +1171,22 @@ def auth_status(): """Health check endpoint.""" return {"status": "ok", "service": "mitai-jinkendo", "version": "v9b"} -@app.post("/api/auth/password-reset-request") +@app.post("/api/auth/pin") +def change_pin(req: dict, session: dict=Depends(require_auth)): + """Change PIN/password for current user.""" + pid = session['profile_id'] + new_pin = req.get('pin', '') + if len(new_pin) < 4: + raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben") + + new_hash = hash_pin(new_pin) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) + + return {"ok": True} + +@app.post("/api/auth/forgot-password") @limiter.limit("3/minute") async def password_reset_request(req: PasswordResetRequest, request: Request): """Request password reset email.""" @@ -1203,7 +1244,7 @@ Dein Mitai Jinkendo Team return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."} -@app.post("/api/auth/password-reset-confirm") +@app.post("/api/auth/reset-password") def password_reset_confirm(req: PasswordResetConfirm): """Confirm password reset with token.""" with get_db() as conn: @@ -1264,9 +1305,79 @@ def admin_update_profile(pid: str, data: AdminProfileUpdate, session: dict=Depen return {"ok": True} -@app.post("/api/admin/test-email") -def admin_test_email(email: str, session: dict=Depends(require_admin)): +@app.put("/api/admin/profiles/{pid}/permissions") +def admin_set_permissions(pid: str, data: dict, session: dict=Depends(require_admin)): + """Admin: Set profile permissions.""" + with get_db() as conn: + cur = get_cursor(conn) + updates = [] + values = [] + if 'ai_enabled' in data: + updates.append('ai_enabled=%s') + values.append(data['ai_enabled']) + if 'ai_limit_day' in data: + updates.append('ai_limit_day=%s') + values.append(data['ai_limit_day']) + if 'export_enabled' in data: + updates.append('export_enabled=%s') + values.append(data['export_enabled']) + if 'role' in data: + updates.append('role=%s') + values.append(data['role']) + + if updates: + cur.execute(f"UPDATE profiles SET {', '.join(updates)} WHERE id=%s", values + [pid]) + + return {"ok": True} + +@app.put("/api/admin/profiles/{pid}/email") +def admin_set_email(pid: str, data: dict, session: dict=Depends(require_admin)): + """Admin: Set profile email.""" + email = data.get('email', '').strip().lower() + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("UPDATE profiles SET email=%s WHERE id=%s", (email if email else None, pid)) + + return {"ok": True} + +@app.put("/api/admin/profiles/{pid}/pin") +def admin_set_pin(pid: str, data: dict, session: dict=Depends(require_admin)): + """Admin: Set profile PIN/password.""" + new_pin = data.get('pin', '') + if len(new_pin) < 4: + raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben") + + new_hash = hash_pin(new_pin) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid)) + + return {"ok": True} + +@app.get("/api/admin/email/status") +def admin_email_status(session: dict=Depends(require_admin)): + """Admin: Check email configuration status.""" + smtp_host = os.getenv("SMTP_HOST") + smtp_user = os.getenv("SMTP_USER") + smtp_pass = os.getenv("SMTP_PASS") + app_url = os.getenv("APP_URL", "http://localhost:3002") + + configured = bool(smtp_host and smtp_user and smtp_pass) + + return { + "configured": configured, + "smtp_host": smtp_host or "", + "smtp_user": smtp_user or "", + "app_url": app_url + } + +@app.post("/api/admin/email/test") +def admin_test_email(data: dict, session: dict=Depends(require_admin)): """Admin: Send test email.""" + email = data.get('to', '') + if not email: + raise HTTPException(400, "E-Mail-Adresse fehlt") + try: import smtplib from email.mime.text import MIMEText @@ -1348,3 +1459,111 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D media_type="text/csv", headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.csv"} ) + +@app.get("/api/export/json") +def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Export all data as JSON.""" + pid = get_pid(x_profile_id) + + # 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 = {} + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + data['profile'] = r2d(cur.fetchone()) + + cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['weight'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['circumferences'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['caliper'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['nutrition'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['activity'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) + data['insights'] = [r2d(r) for r in cur.fetchall()] + + json_str = json.dumps(data, indent=2, default=str) + return Response( + content=json_str, + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.json"} + ) + +@app.get("/api/export/zip") +def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Export all data as ZIP (JSON + photos).""" + pid = get_pid(x_profile_id) + + # 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") + + # Create ZIP in memory + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + # Add JSON data + data = {} + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + data['profile'] = r2d(cur.fetchone()) + + cur.execute("SELECT * FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['weight'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['circumferences'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['caliper'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['nutrition'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) + data['activity'] = [r2d(r) for r in cur.fetchall()] + + cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) + data['insights'] = [r2d(r) for r in cur.fetchall()] + + zf.writestr("data.json", json.dumps(data, indent=2, default=str)) + + # Add photos if they exist + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,)) + photos = [r2d(r) for r in cur.fetchall()] + + for i, photo in enumerate(photos): + photo_path = Path(PHOTOS_DIR) / photo['path'] + if photo_path.exists(): + zf.write(photo_path, f"photos/{photo['date'] or i}_{photo_path.name}") + + zip_buffer.seek(0) + return StreamingResponse( + iter([zip_buffer.getvalue()]), + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename=mitai-export-{pid}.zip"} + )