Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion project/backend/Database.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ def getConnection() -> sqlite3.Connection:
"""Erstellt eine neue SQLite-Connection mit Row-Factory."""
con = sqlite3.connect(str(DB_PATH), check_same_thread=False)
con.row_factory = sqlite3.Row
Comment thread
EdenBernhard marked this conversation as resolved.
con.execute("PRAGMA foreign_keys = ON")
con.execute("PRAGMA journal_mode = WAL")
con.execute("PRAGMA foreign_keys = ON")
return con


Expand Down Expand Up @@ -99,6 +99,19 @@ def initDB():
UNIQUE (AccountID, rid)
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS IngredientUsage (
AccountID INTEGER NOT NULL,
name TEXT NOT NULL,
displayName TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
lastUnit TEXT,
lastUsedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (AccountID, name),
FOREIGN KEY (AccountID) REFERENCES Account (id) ON DELETE CASCADE
)
""")

cur.execute("""
CREATE TABLE IF NOT EXISTS PasswordResetToken (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Expand Down Expand Up @@ -269,6 +282,56 @@ def updateKontoPassword(konto_id: int, hashed_password: str) -> None:
)


# ── Ingredient-Usage-Operationen ───────────────────────────────

def incrementIngredientUsage(AccountID: int, name: str, unit: str | None) -> None:
"""Erhöht den Usage-Counter für eine Zutat um 1, aktualisiert lastUnit und lastUsedAt.
Legt einen neuen Eintrag an, falls die Zutat für diesen Account noch nicht existiert.
Der Name wird normalisiert (trim + lowercase) für den Primary Key, displayName behält
die Originalschreibweise der letzten Eingabe.
"""
displayName = (name or "").strip()
if not displayName:
return
normalizedName = displayName.lower()
with getDB() as con:
cur = con.cursor()
cur.execute(
"""
INSERT INTO IngredientUsage (AccountID, name, displayName, count, lastUnit, lastUsedAt)
VALUES (?, ?, ?, 1, ?, CURRENT_TIMESTAMP)
ON CONFLICT(AccountID, name) DO UPDATE SET
count = count + 1,
displayName = excluded.displayName,
lastUnit = excluded.lastUnit,
lastUsedAt = CURRENT_TIMESTAMP
""",
(AccountID, normalizedName, displayName, unit),
)


def getTopIngredients(AccountID: int, limit: int = 5) -> list[dict]:
"""Liefert die meistgenutzten Zutaten des Users.
Sortierung Hybrid: erst nach count DESC, dann nach lastUsedAt DESC als Tie-Breaker.
"""
con = getConnection()
try:
cur = con.cursor()
cur.execute(
"""
SELECT displayName, lastUnit, count, lastUsedAt
FROM IngredientUsage
WHERE AccountID = ?
ORDER BY count DESC, lastUsedAt DESC
LIMIT ?
""",
(AccountID, limit),
)
return [dict(row) for row in cur.fetchall()]
finally:
Comment on lines +285 to +331
con.close()


def getAccountById(konto_id: int) -> dict | None:
"""Gibt Account-Daten anhand der ID zurück, oder None."""
con = getConnection()
Expand Down
13 changes: 12 additions & 1 deletion project/backend/Models.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,15 @@ class ResetPasswordRequest(BaseModel):
class UpdateUser(BaseModel):
email: str | None = None
currentPassword: str | None = None
newPassword: str | None = None
newPassword: str | None = None


class IngredientSearch(BaseModel):
name: str
amount: float
unit: str


class RecipeSearchRequest(BaseModel):
zutaten: list[IngredientSearch]
servings: int
65 changes: 63 additions & 2 deletions project/backend/Routes.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.security import OAuth2PasswordRequestForm
from EmailService import sendPasswordChangedEmail, sendPasswordResetEmail

Expand All @@ -26,9 +26,11 @@
updateAccount,
markResetTokenUsed,
updateKontoPassword,
incrementIngredientUsage,
getTopIngredients,
)

from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser
from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser, RecipeSearchRequest

# Frontend-URL aus Env, mit Dev-Fallback (Frontend läuft per compose.yaml auf Port 8000)
FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:8000")
Expand Down Expand Up @@ -196,3 +198,62 @@ async def resetPassword(body: ResetPasswordRequest):
print(f"Bestätigungsmail konnte nicht gesendet werden: {e}")

return {"detail": "Passwort erfolgreich zurückgesetzt."}


# ── Recipe-Suche ───────────────────────────────────────────────

@router.post("/recipes/search")
async def searchRecipes(
body: RecipeSearchRequest,
currentUser: Annotated[User, Depends(getCurrentUser)],
):
"""Sucht Rezepte basierend auf den übergebenen Zutaten.

Hinweis: Aktuell nur Usage-Tracking implementiert – die eigentliche
Rezept-Suche folgt. Jede gesuchte Zutat erhöht den persönlichen Counter
des Users (IngredientUsage), damit später die Top-5-Vorschläge daraus
abgeleitet werden können.
"""
Account = getAccountByEmail(currentUser.email)
if Account is None:
raise HTTPException(status_code=404, detail="Account nicht gefunden")

# Usage-Tracking: jede gesuchte Zutat hochzählen (Hybrid: count + lastUsedAt + lastUnit)
for zutat in body.zutaten:
incrementIngredientUsage(Account["id"], zutat.name, zutat.unit)

# Aktualisierte Top 5 direkt mit zurückgeben → Frontend muss keinen Extra-Request machen
topRows = getTopIngredients(Account["id"], limit=5)
topIngredients = [
{"name": r["displayName"], "unit": r["lastUnit"]}
for r in topRows
]

# TODO: eigentliche Rezept-Suche implementieren
return {"rezepte": [], "topIngredients": topIngredients}


# ── Ingredient-Vorschläge ──────────────────────────────────────

@router.get("/ingredients/top")
async def getTopIngredientsForUser(
response: Response,
currentUser: Annotated[User, Depends(getCurrentUser)],
limit: int = 5,
):
"""Liefert die meistgenutzten Zutaten des aktuellen Users für die Vorschlags-Badges."""
Account = getAccountByEmail(currentUser.email)
if Account is None:
raise HTTPException(status_code=404, detail="Account nicht gefunden")

# Caching unterbinden – die Liste ändert sich mit jeder Suche
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"

rows = getTopIngredients(Account["id"], limit=limit)
return {
"ingredients": [
{"name": r["displayName"], "unit": r["lastUnit"]}
for r in rows
]
}
17 changes: 16 additions & 1 deletion project/frontend/app/homepage/forgotPassword.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,34 @@ export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () =>
const [busy, setBusy] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [emailBlurred, setEmailBlurred] = useState(false);
const [error, setError] = useState("");

const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);

const handleSubmit = async () => {
setEmailBlurred(true);
setError("");
if (!emailValid) return;
setBusy(true);
try {
await fetch(`${API_URL}/auth/forgot-password`, {
const res = await fetch(`${API_URL}/auth/forgot-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.detail ?? `Fehler beim Anfordern (Status ${res.status}).`);
return;
}
setSubmitted(true);
} catch (err) {
const msg = err instanceof Error ? err.message : "";
setError(
msg.includes("Failed to fetch") || msg === "Network Error"
? "Backend nicht erreichbar."
: (msg || "Unbekannter Fehler.")
);
} finally {
setBusy(false);
}
Expand Down Expand Up @@ -50,6 +64,7 @@ export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () =>
Gib deine E-Mail-Adresse ein. Wir schicken dir einen Link zum
Zurücksetzen.
</p>
{error && <p className={styles.error}>{error}</p>}
<Field
label="Email"
type="email"
Expand Down
14 changes: 12 additions & 2 deletions project/frontend/app/homepage/signin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,18 @@ export default function LoginForm({ onClose, onSwitch, onForgot}: { onClose: ()
await login(email, password);
onClose();
router.push("/recipeFinder");
} catch {
setError("E-Mail oder Passwort falsch.");
} catch (err) {
// Klassifizieren statt pauschal "Passwort falsch"
const msg = err instanceof Error ? err.message : "";
if (msg === "Login fehlgeschlagen") {
setError("E-Mail oder Passwort falsch.");
} else if (msg.includes("Backend-Response unvollständig")) {
setError("Server-Antwort unvollständig. Backend-Container neu bauen?");
} else if (msg.includes("Failed to fetch") || msg === "Network Error") {
setError("Backend nicht erreichbar.");
} else {
setError(msg || "Unbekannter Fehler beim Anmelden.");
}
} finally {
setBusy(false);
}
Expand Down
22 changes: 19 additions & 3 deletions project/frontend/app/profile/changePasswordPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@ import Field from "@/app/components/fields";

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000";

function PasswordInput({ value, onChange, placeholder, onKeyDown }: {
function PasswordInput({ value, onChange, placeholder, onKeyDown, ariaLabel }: {
value: string;
onChange: (v: string) => void;
placeholder: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
ariaLabel?: string;
}) {
const [show, setShow] = useState(false);
return (
<div style={{ position: "relative", width: "100%" }}>
<input
type={show ? "text" : "password"}
placeholder={placeholder}
aria-label={ariaLabel ?? placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
Expand All @@ -29,7 +31,7 @@ function PasswordInput({ value, onChange, placeholder, onKeyDown }: {
type="button"
onClick={() => setShow((s) => !s)}
aria-label={show ? "Passwort verbergen" : "Passwort anzeigen"}
tabIndex={-1}
aria-pressed={show}
style={{
position: "absolute",
right: 10,
Expand Down Expand Up @@ -70,7 +72,21 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps
async function handlePasswordChange() {
setPasswordMsg("");

// Bestätigung prüfen (nur im forgot-Modus relevant, aber sinnvoll auch beim Ändern)
// Pflichtfelder im "change"-Modus
if (!isForgot && !currentPassword) {
setPasswordMsg("Bitte das aktuelle Passwort eingeben.");
return;
}
if (!newPassword) {
setPasswordMsg("Bitte ein neues Passwort eingeben.");
return;
}
if (!confirmPassword) {
setPasswordMsg("Bitte das neue Passwort bestätigen.");
return;
}

// Bestätigung prüfen
if (newPassword !== confirmPassword) {
setPasswordMsg("Die Passwörter stimmen nicht überein.");
return;
Expand Down
5 changes: 2 additions & 3 deletions project/frontend/app/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import {useEffect, useRef, useState} from "react";
import {useEffect, useState} from "react";
import { useAuth, fetchWithAuth } from "@/lib/auth";
import { useRouter } from "next/navigation";
import {ChefHat} from "lucide-react";
Expand All @@ -17,7 +17,6 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000";
export default function Profile() {
const { user, loading, logout } = useAuth();
const router = useRouter();
const menuRef = useRef<HTMLDivElement>(null);

const [showConfirm, setShowConfirm] = useState(false);

Expand Down Expand Up @@ -94,7 +93,7 @@ export default function Profile() {
</div>
<Button
className="bg-red-600 text-white hover:bg-red-700 text-sm font-medium"
onClick={() => setShowConfirm(true)}
onClick={() => setShowConfirm (true)}
>
Konto löschen
</Button>
Expand Down
Loading
Loading