fix: align all API endpoints between frontend and backend

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 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-18 17:07:41 +01:00
parent 3d58a2db8e
commit 1db780858b

View File

@ -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"}
)