feat: interaction / event logging

New `events` table with (user_id, kind, created_at, data JSON).
Instruments:

Bot:
- cmd.start, cmd.history, cmd.stats, cmd.delete, cmd.export, cmd.feedback
- workout.save (source=text), workout.delete (source=bot)

Server:
- workout.save (source=webapp), workout.update, workout.delete (source=webapp)
- POST /api/events for Mini App client-side events

Mini App:
- miniapp.open on init()
- set.add on addSet(), with exercise name / reps / weight
  (per-set timestamps unlock the rest-timer feature later)

log_event swallows failures so it can never break a caller.
get_events supports user_id / kind filtering for inspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Danny 2026-04-19 14:03:42 +02:00
parent 1d3e7d5e80
commit 52277e99de
5 changed files with 187 additions and 2 deletions

61
db.py
View file

@ -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: