diff --git a/Makefile b/Makefile index 910c66d..692e7bb 100644 --- a/Makefile +++ b/Makefile @@ -128,6 +128,17 @@ staging: check-ssh @echo " ✓ Staging fertig — https://staging.banyaro.app" @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_STAGING) --tail=10" +# ---------------------------------------------------------- +# STAGING-DB — Produktions-DB in Staging kopieren (interaktiv, braucht sudo) +# Aufruf: make staging-db +# ---------------------------------------------------------- +staging-db: check-ssh + @echo "→ Produktions-DB nach Staging kopieren..." + @ssh -t $(DS_HOST) " \ + sudo cp $(DS_PATH)/data/banyaro.db $(DS_PATH_STAGING)/data/banyaro.db && \ + sudo chmod 666 $(DS_PATH_STAGING)/data/banyaro.db && \ + echo '✓ DB kopiert'" + # ---------------------------------------------------------- # RELEASE — develop → main → Production (VERSION= pflichtangabe) # Beispiel: make release VERSION=1.1.0 diff --git a/backend/database.py b/backend/database.py index e373f02..1a70aa5 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1657,3 +1657,221 @@ def _migrate(conn_factory): ); CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id); """) + + # Trainings-Streak-Tabelle + conn.execute(""" + CREATE TABLE IF NOT EXISTS training_streaks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + current_streak INTEGER NOT NULL DEFAULT 0, + longest_streak INTEGER NOT NULL DEFAULT 0, + last_training_date TEXT, + UNIQUE(user_id, dog_id) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_streaks_user ON training_streaks(user_id)") + + # Ausgaben-Tracker + conn.executescript(""" + CREATE TABLE IF NOT EXISTS expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + kategorie TEXT NOT NULL, + betrag REAL NOT NULL, + datum TEXT NOT NULL, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_expenses_user ON expenses(user_id, datum DESC); + """) + + # KI-Tierarztfragen Rate-Limit-Log + conn.execute(""" + CREATE TABLE IF NOT EXISTS ki_tierarzt_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + + # KI Rassen-Erkennungs-Log (Rate-Limit: 10/Tag pro User) + conn.execute(""" + CREATE TABLE IF NOT EXISTS ki_rasse_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_ki_rasse_log_user + ON ki_rasse_log(user_id, created_at DESC) + """) + + # feed_recalls — Rückruf-Alarm für Tierfutter (RASFF) + conn.execute(""" + CREATE TABLE IF NOT EXISTS feed_recalls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_id TEXT NOT NULL UNIQUE, + titel TEXT NOT NULL, + produkt TEXT, + gefahr TEXT, + herkunft TEXT, + datum TEXT NOT NULL, + quelle TEXT NOT NULL DEFAULT 'rasff', + url TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_recalls_datum ON feed_recalls(datum DESC)") + + # Adoption-Cache + conn.execute(""" + CREATE TABLE IF NOT EXISTS adoption_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + external_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + rasse TEXT, + alter_jahre REAL, + geschlecht TEXT, + foto_url TEXT, + tierheim TEXT, + tierheim_plz TEXT, + tierheim_lat REAL, + tierheim_lon REAL, + adoptions_url TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL + ) + """) + + # ---- Favoriten-Tierarzt + Gesundheitsdokumente ---- + conn.execute(""" + CREATE TABLE IF NOT EXISTS favorite_vets ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + vet_id INTEGER NOT NULL REFERENCES tieraerzte(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, vet_id) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS health_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + typ TEXT NOT NULL, + titel TEXT NOT NULL, + beschreibung TEXT, + file_path TEXT NOT NULL, + file_type TEXT NOT NULL, + datum TEXT, + vet_id INTEGER REFERENCES tieraerzte(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_health_docs_dog ON health_documents(dog_id, created_at DESC)") + + # Digitaler Hundepass — Impfungen, Medikamente, Metadaten, Share-Links + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS vaccinations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + krankheit TEXT NOT NULL, + datum TEXT NOT NULL, + naechste TEXT, + tierarzt TEXT, + charge_nr TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS medications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + name TEXT NOT NULL, + dosierung TEXT, + von TEXT, + bis TEXT, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS dog_passport_meta ( + dog_id INTEGER PRIMARY KEY REFERENCES dogs(id) ON DELETE CASCADE, + blutgruppe TEXT, + allergien TEXT, + besonderheiten TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS passport_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + valid_until TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_passport_shares_token ON passport_shares(token) + """) + logger.info("Migration: Hundepass-Tabellen bereit.") + except Exception as e: + logger.warning(f"Migration Hundepass: {e}") + + # ---- Playdate ---- + conn.execute(""" + CREATE TABLE IF NOT EXISTS playdate_listings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + lat REAL NOT NULL, + lon REAL NOT NULL, + ort_name TEXT, + radius_km INTEGER NOT NULL DEFAULT 10, + beschreibung TEXT, + aktiv INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(dog_id) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_playdate_listings_geo + ON playdate_listings(lat, lon) WHERE aktiv=1 + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS playdate_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + to_dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, + from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + nachricht TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(from_dog_id, to_dog_id) + ) + """) + + # Wiederkehrende Ausgaben (Daueraufträge) + conn.executescript(""" + CREATE TABLE IF NOT EXISTS recurring_expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, + kategorie TEXT NOT NULL, + betrag REAL NOT NULL, + haeufigkeit TEXT NOT NULL, -- monatlich|quartalsweise|jaehrlich + startdatum TEXT NOT NULL, + naechste_faelligkeit TEXT NOT NULL, + notiz TEXT, + aktiv INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv); + """) diff --git a/backend/main.py b/backend/main.py index 6eb99a2..8b259f7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -189,6 +189,13 @@ from routes.zucht_ki import router as zucht_ki_router from routes.partner import router as partner_router from routes.outreach import router as outreach_router from routes.jobs import router as jobs_router +from routes.streak import router as streak_router +from routes.expenses import router as expenses_router +from routes.recalls import router as recalls_router +from routes.adoption import router as adoption_router +from routes.health_docs import router as health_docs_router +from routes.passport import router as passport_router +from routes.playdate import router as playdate_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -240,6 +247,13 @@ app.include_router(training_router, prefix="/api/training", tags= app.include_router(praise_router, prefix="/api/praise", tags=["Praise"]) app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"]) app.include_router(notes_router, prefix="/api/notes", tags=["Notes"]) +app.include_router(streak_router, prefix="/api", tags=["Streak"]) +app.include_router(expenses_router, prefix="/api/expenses", tags=["Ausgaben"]) +app.include_router(recalls_router, prefix="/api/recalls", tags=["Rückrufe"]) +app.include_router(adoption_router, prefix="/api/adoption", tags=["Adoption"]) +app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"]) +app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"]) +app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"]) # ------------------------------------------------------------------ @@ -1674,6 +1688,152 @@ for _hp in _HONEYPOT_PATHS: app.add_api_route(_hp, _honeypot_handler, methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False) +# ------------------------------------------------------------------ +# Digitaler Hundepass — öffentlicher Share-Link (kein Login nötig) +# ------------------------------------------------------------------ +@app.get("/pass/{token}") +async def passport_share_page(token: str): + from fastapi.responses import HTMLResponse + from database import db as _db + from datetime import date as _date + + with _db() as conn: + share = conn.execute( + "SELECT * FROM passport_shares WHERE token=?", (token,) + ).fetchone() + if not share: + return HTMLResponse( + '' + '
Dieser Hundepass-Link ist ungültig.
', + status_code=404 + ) + if share["valid_until"] < _date.today().isoformat(): + return HTMLResponse( + '' + 'Dieser Hundepass-Link ist nicht mehr gültig.
', + status_code=410 + ) + dog_id = share["dog_id"] + dog = conn.execute("SELECT * FROM dogs WHERE id=?", (dog_id,)).fetchone() + meta = conn.execute("SELECT * FROM dog_passport_meta WHERE dog_id=?", (dog_id,)).fetchone() + vaccs = conn.execute( + "SELECT * FROM vaccinations WHERE dog_id=? ORDER BY datum DESC", (dog_id,) + ).fetchall() + meds = conn.execute( + "SELECT * FROM medications WHERE dog_id=? ORDER BY von DESC, id DESC", (dog_id,) + ).fetchall() + def _fmt(d): + if not d: + return "–" + try: + from datetime import datetime as _dt + return _dt.strptime(d[:10], "%Y-%m-%d").strftime("%d.%m.%Y") + except Exception: + return d + + dog = dict(dog) + meta = dict(meta) if meta else {} + vaccs = [dict(v) for v in vaccs] + meds = [dict(m) for m in meds] + + _g_map = {"m": "Rüde", "w": "Hündin"} + + vacc_rows = "".join(f""" +Digitaler Hundepass — {dog['name']}
+| Krankheit | Datum | Nächste | Tierarzt | Charge | +
|---|
| Medikament | Dosierung | Von | Bis | Notiz | +
|---|
+ Noch keine Befunde hochgeladen. +
`; + + return ` ++ Beschreibe die Symptome deines Hundes. Die KI gibt eine erste Orientierung — + kein Ersatz für einen echten Tierarzt. +
+')
+ .replace(/\n/g, '
');
+ const restHtml = result.limit - result.anfragen_heute > 0
+ ? `
+ Noch ${result.limit - result.anfragen_heute} von ${result.limit} Anfragen heute verfügbar. +
` + : `+ Tageslimit erreicht. Morgen wieder verfügbar. +
`; + + resultEl.innerHTML = ` +${antwortHtml}
+ ${restHtml} ++ Standort wird ermittelt… +
+
+ Standort konnte nicht automatisch ermittelt werden.
+ Klicke auf "Standort aktualisieren".
+
${UI.icon('spinner')} Suche…
`; + + try { + const data = await API.get(`/playdate/nearby?lat=${_userPos.lat}&lon=${_userPos.lon}&radius=${_radius}`); + + if (!data || data.length === 0) { + resultsEl.innerHTML = UI.emptyState({ + icon: UI.icon('paw-print'), + title: 'Niemand in der Nähe', + text: `Aktuell hat sich noch niemand in ${_radius} km Umkreis eingetragen. Trage deinen Hund unter "Meine Inserate" ein!`, + }); + return; + } + + resultsEl.innerHTML = ` +${err.message}
`; + } + } + + function _nearbyCard(d) { + return ` ++ ${_esc(d.beschreibung)} +
` : ''} + +${UI.icon('spinner')} Lädt…
`; + await _loadListings(el); + } + + async function _loadListings(el) { + const target = el || document.getElementById('playdate-content'); + if (!target) return; + + if (_dogs.length === 0) { + target.innerHTML = UI.emptyState({ + icon: UI.icon('paw-print'), + title: 'Noch kein Hund', + text: 'Lege zuerst einen Hund in deinem Profil an.', + action: `${_esc(listing.beschreibung)}
` : ''} + ` : ` ++ Noch kein Inserat — trage dich ein, damit andere dich finden können. +
+ `} + +${UI.icon('spinner')} Lädt…
`; + try { + const data = await API.get('/playdate/requests'); + const incoming = data.incoming || []; + const outgoing = data.outgoing || []; + + // Badge aktualisieren + const pendingCount = incoming.filter(r => r.status === 'pending').length; + const badge = document.getElementById('playdate-req-badge'); + if (badge) { + badge.textContent = pendingCount; + badge.style.display = pendingCount > 0 ? '' : 'none'; + } + + if (incoming.length === 0 && outgoing.length === 0) { + el.innerHTML = UI.emptyState({ + icon: UI.icon('paw-print'), + title: 'Noch keine Anfragen', + text: 'Wenn du oder jemand anderes eine Playdate-Anfrage schickt, erscheint sie hier.', + }); + return; + } + + el.innerHTML = ` +${err.message}
`; + } + } + + function _incomingCard(r) { + const isPending = r.status === 'pending'; + return ` ++ "${_esc(r.nachricht)}" +
` : ''} + + ${r.status === 'accepted' ? ` ++ ${UI.escape(r.gefahr)} +
` : ''} + + +Wird geladen…
', + }); + let board; + try { board = await API.get('/streak/leaderboard'); } catch { board = []; } + const bodyEl = modalEl?.querySelector('.modal-body'); + if (bodyEl) bodyEl.innerHTML = _leaderboardHTML(board); + }); + } + + function _streakWidgetHTML(s) { + const cur = s.current_streak || 0; + const best = s.longest_streak || 0; + return ` +Noch keine Einträge.
'; + } + const medals = ['🥇', '🥈', '🥉']; + return ` +