diff --git a/bot.py b/bot.py index e08f3dc..cd9ab46 100644 --- a/bot.py +++ b/bot.py @@ -16,7 +16,7 @@ from telegram.ext import ( filters, ) -from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, save_feedback, get_user_workout_number, resolve_user_number +from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, save_feedback, get_user_workout_number, resolve_user_number, log_event from parser import parse_workout, format_workout load_dotenv() @@ -78,6 +78,7 @@ def extract_timestamp(update: Update) -> tuple[datetime, bool]: async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): + log_event(update.effective_user.id, "cmd.start") text = ( "\U0001f4aa Fitness Tracker Bot\n\n" "Send me your workout and I'll save it!\n\n" @@ -112,6 +113,7 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE): user_id = update.effective_user.id + log_event(user_id, "cmd.history") workouts = get_workouts(user_id, limit=5) if not workouts: @@ -134,6 +136,7 @@ async def cmd_history(update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): user_id = update.effective_user.id + log_event(user_id, "cmd.stats") stats = get_stats_sql(user_id) if stats["total_workouts"] == 0: @@ -152,6 +155,7 @@ async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE): async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE): user_id = update.effective_user.id + log_event(user_id, "cmd.delete", {"args": context.args or []}) if not context.args: await update.message.reply_text( @@ -169,6 +173,7 @@ async def cmd_delete(update: Update, context: ContextTypes.DEFAULT_TYPE): workout_id = resolve_user_number(user_id, user_number) if workout_id is not None and delete_workout(user_id, workout_id): + log_event(user_id, "workout.delete", {"workout_id": workout_id, "user_number": user_number}) await update.message.reply_text(f"\U0001f5d1 Workout #{user_number} deleted.") else: await update.message.reply_text( @@ -183,6 +188,7 @@ async def cmd_export(update: Update, context: ContextTypes.DEFAULT_TYPE): from db import export_workouts user_id = update.effective_user.id + log_event(user_id, "cmd.export") data = export_workouts(user_id) if not data: @@ -211,6 +217,7 @@ async def cmd_feedback(update: Update, context: ContextTypes.DEFAULT_TYPE): return save_feedback(user_id, text) + log_event(user_id, "cmd.feedback") await update.message.reply_text("\U0001f4dd Feedback saved, thanks!") @@ -249,6 +256,12 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): superset_dicts = [[ex.to_dict() for ex in group] for group in groups] workout_id = save_workout(user_id, timestamp, superset_dicts, raw_text=text) user_number = get_user_workout_number(user_id, workout_id) or workout_id + log_event(user_id, "workout.save", { + "source": "text", + "workout_id": workout_id, + "user_number": user_number, + "forwarded": is_forwarded, + }) # Count totals for the confirmation total_exercises = sum(len(g) for g in groups) diff --git a/db.py b/db.py index 36983c8..595ebf7 100644 --- a/db.py +++ b/db.py @@ -67,6 +67,19 @@ def init_db(): text TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + kind TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + data TEXT -- optional JSON payload + ); + + CREATE INDEX IF NOT EXISTS idx_events_user_created + ON events(user_id, created_at); + CREATE INDEX IF NOT EXISTS idx_events_kind_created + ON events(kind, created_at); """) # Migrations @@ -312,6 +325,54 @@ def export_workouts(user_id: int) -> list[dict]: return [dict(r) for r in rows] +def log_event(user_id: int | None, kind: str, data: dict | None = None) -> int: + """Record a user event for audit / telemetry. Failures are swallowed so + logging never breaks a caller.""" + try: + with get_db() as conn: + cur = conn.execute( + "INSERT INTO events (user_id, kind, data) VALUES (?, ?, ?)", + (user_id, kind, json.dumps(data) if data else None), + ) + return cur.lastrowid + except Exception: + return -1 + + +def get_events( + user_id: int | None = None, + kind: str | None = None, + limit: int = 100, +) -> list[dict]: + """Fetch events, newest first. Filter by user_id and/or kind if given.""" + where = [] + params: list = [] + if user_id is not None: + where.append("user_id = ?") + params.append(user_id) + if kind is not None: + where.append("kind = ?") + params.append(kind) + sql = "SELECT id, user_id, kind, created_at, data FROM events" + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY created_at DESC, id DESC LIMIT ?" + params.append(limit) + + with get_db() as conn: + rows = conn.execute(sql, params).fetchall() + out = [] + for r in rows: + d = dict(r) + if d.get("data"): + try: + d["data"] = json.loads(d["data"]) + except json.JSONDecodeError: + pass + out.append(d) + return out + + def save_feedback(user_id: int, text: str) -> int: """Save user feedback. Returns the feedback id.""" with get_db() as conn: diff --git a/server.py b/server.py index c63c062..0f94143 100644 --- a/server.py +++ b/server.py @@ -17,7 +17,7 @@ from urllib.parse import parse_qs from aiohttp import web -from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names +from db import init_db, save_workout, get_workouts, get_workout_count, get_stats_sql, delete_workout, update_workout, export_workouts, get_user_workout_number, get_all_exercise_names, log_event from parser import parse_workout, format_workout logging.basicConfig( @@ -228,6 +228,11 @@ async def api_save_workout(request: web.Request): ) user_number = get_user_workout_number(request["user_id"], workout_id) + log_event(request["user_id"], "workout.save", { + "source": "webapp", + "workout_id": workout_id, + "user_number": user_number, + }) return web.json_response( {"workout_id": workout_id, "user_number": user_number}, status=201, @@ -249,6 +254,11 @@ async def api_update_workout(request: web.Request): if new_id is None: return web.json_response({"error": "Not found"}, status=404) user_number = get_user_workout_number(request["user_id"], new_id) + log_event(request["user_id"], "workout.update", { + "old_workout_id": workout_id, + "workout_id": new_id, + "user_number": user_number, + }) return web.json_response({"workout_id": new_id, "user_number": user_number}) @@ -257,6 +267,10 @@ async def api_delete_workout(request: web.Request): """Soft-delete a workout by ID.""" workout_id = int(request.match_info["workout_id"]) if delete_workout(request["user_id"], workout_id): + log_event(request["user_id"], "workout.delete", { + "source": "webapp", + "workout_id": workout_id, + }) return web.json_response({"deleted": True}) return web.json_response({"error": "Not found"}, status=404) @@ -288,6 +302,21 @@ async def api_version(request: web.Request): return web.json_response({"version": _VERSION}) +@require_auth +async def api_log_event(request: web.Request): + """Record a client-emitted event (Mini App telemetry).""" + try: + body = await request.json() + except (ValueError, json.JSONDecodeError): + return web.json_response({"error": "Invalid JSON"}, status=400) + kind = body.get("kind") + if not isinstance(kind, str) or not kind: + return web.json_response({"error": "Missing kind"}, status=400) + data = body.get("data") if isinstance(body.get("data"), dict) else None + log_event(request["user_id"], kind, data) + return web.Response(status=204) + + @require_auth async def api_export_csv(request: web.Request): """Export all workouts as CSV.""" @@ -322,6 +351,7 @@ def create_app() -> web.Application: app.router.add_get("/api/export/json", api_export_json) app.router.add_get("/api/export/csv", api_export_csv) app.router.add_get("/api/version", api_version) + app.router.add_post("/api/events", api_log_event) # Serve the webapp/ folder webapp_dir = pathlib.Path(__file__).parent / "webapp" diff --git a/tests/test_db.py b/tests/test_db.py index 1e87115..1660b28 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -253,6 +253,62 @@ class TestAllExerciseNames: assert db.get_all_exercise_names() == ["Apple", "Mango", "Zebra"] +# ── events / log_event ─────────────────────────────────────────── + + +class TestEvents: + def test_log_and_fetch(self, tmp_db): + db.log_event(1, "cmd.start") + events = db.get_events() + assert len(events) == 1 + assert events[0]["user_id"] == 1 + assert events[0]["kind"] == "cmd.start" + assert events[0]["data"] is None + + def test_log_with_data(self, tmp_db): + db.log_event(1, "set.add", {"exercise": "Bench", "reps": 8, "weight_kg": 35.0}) + events = db.get_events() + assert events[0]["data"] == {"exercise": "Bench", "reps": 8, "weight_kg": 35.0} + + def test_filter_by_user(self, tmp_db): + db.log_event(1, "cmd.start") + db.log_event(2, "cmd.start") + db.log_event(1, "cmd.history") + assert {e["kind"] for e in db.get_events(user_id=1)} == {"cmd.start", "cmd.history"} + assert {e["kind"] for e in db.get_events(user_id=2)} == {"cmd.start"} + + def test_filter_by_kind(self, tmp_db): + db.log_event(1, "cmd.start") + db.log_event(1, "set.add", {"reps": 5}) + db.log_event(2, "set.add", {"reps": 3}) + sets = db.get_events(kind="set.add") + assert len(sets) == 2 + assert all(e["kind"] == "set.add" for e in sets) + + def test_newest_first(self, tmp_db): + db.log_event(1, "first") + db.log_event(1, "second") + db.log_event(1, "third") + kinds = [e["kind"] for e in db.get_events()] + assert kinds == ["third", "second", "first"] + + def test_limit(self, tmp_db): + for i in range(5): + db.log_event(1, f"k{i}") + assert len(db.get_events(limit=2)) == 2 + + def test_null_user_allowed(self, tmp_db): + db.log_event(None, "system.tick") + events = db.get_events() + assert events[0]["user_id"] is None + + def test_log_failure_returns_minus_one(self, tmp_db): + # Simulate failure by passing unserializable data + class X: pass + result = db.log_event(1, "bad", {"obj": X()}) + assert result == -1 + + # ── update_workout ─────────────────────────────────────────────── diff --git a/webapp/app.js b/webapp/app.js index 51ea3ae..0a46f75 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -29,6 +29,24 @@ async function api(method, path, body = null) { return res.json(); } +// ── Event logging (fire-and-forget) ───────────────────────────── +function logEvent(kind, data) { + if (!userId) return; + try { + fetch(API + "/events", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Telegram-Init-Data": tg.initData, + }, + body: JSON.stringify({ kind, data: data || null }), + keepalive: true, + }).catch(() => {}); + } catch (e) { + // Never let logging break anything + } +} + // ── Toast ─────────────────────────────────────────────────────── function showToast(msg) { let toast = document.querySelector(".toast"); @@ -330,6 +348,12 @@ function addSet() { addSetToDOM(reps, weight); syncEditorUI(); + logEvent("set.add", { + exercise: currentExercise?.name || null, + reps, + weight_kg: weight, + }); + repsInput.value = ""; weightInput.value = weight ? String(weight) : ""; repsInput.focus(); @@ -784,6 +808,7 @@ async function loadVersion() { async function init() { loadVersion(); if (!userId) return; + logEvent("miniapp.open"); try { const data = await api("GET", "/exercises"); knownExercises = data.exercises || [];