404 lines
19 KiB
Python
404 lines
19 KiB
Python
"""
|
|
Routines API - Brilli-style routine management
|
|
|
|
Routines have ordered steps. Users start sessions to walk through them.
|
|
"""
|
|
|
|
import os
|
|
import uuid
|
|
import flask
|
|
import jwt
|
|
import core.auth as auth
|
|
import core.postgres as postgres
|
|
|
|
|
|
def _get_user_uuid(token):
|
|
try:
|
|
payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
|
|
return payload.get("sub")
|
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
|
return None
|
|
|
|
|
|
def _auth(request):
|
|
"""Extract and verify token. Returns user_uuid or None."""
|
|
header = request.headers.get("Authorization", "")
|
|
if not header.startswith("Bearer "):
|
|
return None
|
|
token = header[7:]
|
|
user_uuid = _get_user_uuid(token)
|
|
if not user_uuid or not auth.verifyLoginToken(token, userUUID=user_uuid):
|
|
return None
|
|
return user_uuid
|
|
|
|
|
|
def register(app):
|
|
|
|
# ── Routines CRUD ─────────────────────────────────────────────
|
|
|
|
@app.route("/api/routines", methods=["GET"])
|
|
def api_listRoutines():
|
|
"""List all routines for the logged-in user."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
routines = postgres.select("routines", where={"user_uuid": user_uuid}, order_by="name")
|
|
return flask.jsonify(routines), 200
|
|
|
|
@app.route("/api/routines", methods=["POST"])
|
|
def api_createRoutine():
|
|
"""Create a new routine. Body: {name, description?, icon?}"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
data = flask.request.get_json()
|
|
if not data:
|
|
return flask.jsonify({"error": "missing body"}), 400
|
|
if not data.get("name"):
|
|
return flask.jsonify({"error": "missing required field: name"}), 400
|
|
data["id"] = str(uuid.uuid4())
|
|
data["user_uuid"] = user_uuid
|
|
routine = postgres.insert("routines", data)
|
|
return flask.jsonify(routine), 201
|
|
|
|
@app.route("/api/routines/<routine_id>", methods=["GET"])
|
|
def api_getRoutine(routine_id):
|
|
"""Get a routine with its steps."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
if not routine:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
steps = postgres.select(
|
|
"routine_steps",
|
|
where={"routine_id": routine_id},
|
|
order_by="position",
|
|
)
|
|
return flask.jsonify({"routine": routine, "steps": steps}), 200
|
|
|
|
@app.route("/api/routines/<routine_id>", methods=["PUT"])
|
|
def api_updateRoutine(routine_id):
|
|
"""Update routine details. Body: {name?, description?, icon?}"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
data = flask.request.get_json()
|
|
if not data:
|
|
return flask.jsonify({"error": "missing body"}), 400
|
|
existing = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
if not existing:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
allowed = ["name", "description", "icon"]
|
|
updates = {k: v for k, v in data.items() if k in allowed}
|
|
if not updates:
|
|
return flask.jsonify({"error": "no valid fields to update"}), 400
|
|
result = postgres.update("routines", updates, {"id": routine_id, "user_uuid": user_uuid})
|
|
return flask.jsonify(result[0] if result else {}), 200
|
|
|
|
@app.route("/api/routines/<routine_id>", methods=["DELETE"])
|
|
def api_deleteRoutine(routine_id):
|
|
"""Delete a routine and all its steps/sessions."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
existing = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
if not existing:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
postgres.delete("routine_sessions", {"routine_id": routine_id})
|
|
postgres.delete("routine_steps", {"routine_id": routine_id})
|
|
postgres.delete("routine_schedules", {"routine_id": routine_id})
|
|
postgres.delete("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
return flask.jsonify({"deleted": True}), 200
|
|
|
|
# ── Steps CRUD ────────────────────────────────────────────────
|
|
|
|
@app.route("/api/routines/<routine_id>/steps", methods=["GET"])
|
|
def api_listSteps(routine_id):
|
|
"""List steps for a routine, ordered by position."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
if not routine:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
steps = postgres.select(
|
|
"routine_steps",
|
|
where={"routine_id": routine_id},
|
|
order_by="position",
|
|
)
|
|
return flask.jsonify(steps), 200
|
|
|
|
@app.route("/api/routines/<routine_id>/steps", methods=["POST"])
|
|
def api_addStep(routine_id):
|
|
"""Add a step to a routine. Body: {name, duration_minutes?, position?}"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
if not routine:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
data = flask.request.get_json()
|
|
if not data:
|
|
return flask.jsonify({"error": "missing body"}), 400
|
|
if not data.get("name"):
|
|
return flask.jsonify({"error": "missing required field: name"}), 400
|
|
max_pos = postgres.select(
|
|
"routine_steps",
|
|
where={"routine_id": routine_id},
|
|
order_by="position DESC",
|
|
limit=1,
|
|
)
|
|
next_pos = (max_pos[0]["position"] + 1) if max_pos else 1
|
|
step = {
|
|
"id": str(uuid.uuid4()),
|
|
"routine_id": routine_id,
|
|
"name": data["name"],
|
|
"duration_minutes": data.get("duration_minutes"),
|
|
"position": data.get("position", next_pos),
|
|
}
|
|
result = postgres.insert("routine_steps", step)
|
|
return flask.jsonify(result), 201
|
|
|
|
@app.route("/api/routines/<routine_id>/steps/<step_id>", methods=["PUT"])
|
|
def api_updateStep(routine_id, step_id):
|
|
"""Update a step. Body: {name?, duration_minutes?, position?}"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
if not routine:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
data = flask.request.get_json()
|
|
if not data:
|
|
return flask.jsonify({"error": "missing body"}), 400
|
|
existing = postgres.select_one("routine_steps", {"id": step_id, "routine_id": routine_id})
|
|
if not existing:
|
|
return flask.jsonify({"error": "step not found"}), 404
|
|
allowed = ["name", "duration_minutes", "position"]
|
|
updates = {k: v for k, v in data.items() if k in allowed}
|
|
if not updates:
|
|
return flask.jsonify({"error": "no valid fields to update"}), 400
|
|
result = postgres.update("routine_steps", updates, {"id": step_id, "routine_id": routine_id})
|
|
return flask.jsonify(result[0] if result else {}), 200
|
|
|
|
@app.route("/api/routines/<routine_id>/steps/<step_id>", methods=["DELETE"])
|
|
def api_deleteStep(routine_id, step_id):
|
|
"""Delete a step from a routine."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
if not routine:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
existing = postgres.select_one("routine_steps", {"id": step_id, "routine_id": routine_id})
|
|
if not existing:
|
|
return flask.jsonify({"error": "step not found"}), 404
|
|
postgres.delete("routine_steps", {"id": step_id, "routine_id": routine_id})
|
|
return flask.jsonify({"deleted": True}), 200
|
|
|
|
@app.route("/api/routines/<routine_id>/steps/reorder", methods=["PUT"])
|
|
def api_reorderSteps(routine_id):
|
|
"""Reorder steps. Body: {step_ids: [ordered list of step UUIDs]}"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
if not routine:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
data = flask.request.get_json()
|
|
if not data or not data.get("step_ids"):
|
|
return flask.jsonify({"error": "missing step_ids"}), 400
|
|
step_ids = data["step_ids"]
|
|
for i, step_id in enumerate(step_ids):
|
|
postgres.update("routine_steps", {"position": i + 1}, {"id": step_id, "routine_id": routine_id})
|
|
steps = postgres.select(
|
|
"routine_steps",
|
|
where={"routine_id": routine_id},
|
|
order_by="position",
|
|
)
|
|
return flask.jsonify(steps), 200
|
|
|
|
# ── Routine Sessions (active run-through) ─────────────────────
|
|
|
|
@app.route("/api/routines/<routine_id>/start", methods=["POST"])
|
|
def api_startRoutine(routine_id):
|
|
"""Start a routine session. Returns the session with first step."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
if not routine:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
active = postgres.select_one("routine_sessions", {"user_uuid": user_uuid, "status": "active"})
|
|
if active:
|
|
return flask.jsonify({"error": "already have active session", "session_id": active["id"]}), 409
|
|
steps = postgres.select(
|
|
"routine_steps",
|
|
where={"routine_id": routine_id},
|
|
order_by="position",
|
|
)
|
|
if not steps:
|
|
return flask.jsonify({"error": "no steps in routine"}), 400
|
|
session = {
|
|
"id": str(uuid.uuid4()),
|
|
"routine_id": routine_id,
|
|
"user_uuid": user_uuid,
|
|
"status": "active",
|
|
"current_step_index": 0,
|
|
}
|
|
result = postgres.insert("routine_sessions", session)
|
|
return flask.jsonify({"session": result, "current_step": steps[0]}), 201
|
|
|
|
@app.route("/api/sessions/active", methods=["GET"])
|
|
def api_getActiveSession():
|
|
"""Get the user's currently active routine session, if any."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
session = postgres.select_one("routine_sessions", {"user_uuid": user_uuid, "status": "active"})
|
|
if not session:
|
|
return flask.jsonify({"error": "no active session"}), 404
|
|
routine = postgres.select_one("routines", {"id": session["routine_id"]})
|
|
steps = postgres.select(
|
|
"routine_steps",
|
|
where={"routine_id": session["routine_id"]},
|
|
order_by="position",
|
|
)
|
|
current_step = steps[session["current_step_index"]] if session["current_step_index"] < len(steps) else None
|
|
return flask.jsonify({"session": session, "routine": routine, "current_step": current_step}), 200
|
|
|
|
@app.route("/api/sessions/<session_id>/complete-step", methods=["POST"])
|
|
def api_completeStep(session_id):
|
|
"""Mark current step done, advance to next. Body: {step_id}"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
session = postgres.select_one("routine_sessions", {"id": session_id, "user_uuid": user_uuid})
|
|
if not session:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
if session["status"] != "active":
|
|
return flask.jsonify({"error": "session not active"}), 400
|
|
data = flask.request.get_json() or {}
|
|
steps = postgres.select(
|
|
"routine_steps",
|
|
where={"routine_id": session["routine_id"]},
|
|
order_by="position",
|
|
)
|
|
next_index = session["current_step_index"] + 1
|
|
if next_index >= len(steps):
|
|
postgres.update("routine_sessions", {"status": "completed"}, {"id": session_id})
|
|
return flask.jsonify({"session": {"status": "completed"}, "next_step": None}), 200
|
|
postgres.update("routine_sessions", {"current_step_index": next_index}, {"id": session_id})
|
|
return flask.jsonify({"session": {"current_step_index": next_index}, "next_step": steps[next_index]}), 200
|
|
|
|
@app.route("/api/sessions/<session_id>/skip-step", methods=["POST"])
|
|
def api_skipStep(session_id):
|
|
"""Skip current step, advance to next. Body: {step_id}"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
session = postgres.select_one("routine_sessions", {"id": session_id, "user_uuid": user_uuid})
|
|
if not session:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
if session["status"] != "active":
|
|
return flask.jsonify({"error": "session not active"}), 400
|
|
steps = postgres.select(
|
|
"routine_steps",
|
|
where={"routine_id": session["routine_id"]},
|
|
order_by="position",
|
|
)
|
|
next_index = session["current_step_index"] + 1
|
|
if next_index >= len(steps):
|
|
postgres.update("routine_sessions", {"status": "completed"}, {"id": session_id})
|
|
return flask.jsonify({"session": {"status": "completed"}, "next_step": None}), 200
|
|
postgres.update("routine_sessions", {"current_step_index": next_index}, {"id": session_id})
|
|
return flask.jsonify({"session": {"current_step_index": next_index}, "next_step": steps[next_index]}), 200
|
|
|
|
@app.route("/api/sessions/<session_id>/cancel", methods=["POST"])
|
|
def api_cancelSession(session_id):
|
|
"""Cancel an active routine session."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
session = postgres.select_one("routine_sessions", {"id": session_id, "user_uuid": user_uuid})
|
|
if not session:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
postgres.update("routine_sessions", {"status": "cancelled"}, {"id": session_id})
|
|
return flask.jsonify({"session": {"status": "cancelled"}}), 200
|
|
|
|
# ── Routine History / Stats ───────────────────────────────────
|
|
|
|
@app.route("/api/routines/<routine_id>/history", methods=["GET"])
|
|
def api_routineHistory(routine_id):
|
|
"""Get past sessions for a routine. Query: ?days=7"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
if not routine:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
days = flask.request.args.get("days", 7, type=int)
|
|
sessions = postgres.select(
|
|
"routine_sessions",
|
|
where={"routine_id": routine_id},
|
|
order_by="created_at DESC",
|
|
limit=days,
|
|
)
|
|
return flask.jsonify(sessions), 200
|
|
|
|
# ── Routine Scheduling ────────────────────────────────────────
|
|
|
|
@app.route("/api/routines/<routine_id>/schedule", methods=["PUT"])
|
|
def api_setRoutineSchedule(routine_id):
|
|
"""Set when this routine should run. Body: {days: ["mon","tue",...], time: "08:00", remind: true}"""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
if not routine:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
data = flask.request.get_json()
|
|
if not data:
|
|
return flask.jsonify({"error": "missing body"}), 400
|
|
existing = postgres.select_one("routine_schedules", {"routine_id": routine_id})
|
|
schedule_data = {
|
|
"routine_id": routine_id,
|
|
"days": data.get("days", []),
|
|
"time": data.get("time"),
|
|
"remind": data.get("remind", True),
|
|
}
|
|
if existing:
|
|
result = postgres.update("routine_schedules", schedule_data, {"routine_id": routine_id})
|
|
return flask.jsonify(result[0] if result else {}), 200
|
|
else:
|
|
schedule_data["id"] = str(uuid.uuid4())
|
|
result = postgres.insert("routine_schedules", schedule_data)
|
|
return flask.jsonify(result), 201
|
|
|
|
@app.route("/api/routines/<routine_id>/schedule", methods=["GET"])
|
|
def api_getRoutineSchedule(routine_id):
|
|
"""Get the schedule for a routine."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
if not routine:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
schedule = postgres.select_one("routine_schedules", {"routine_id": routine_id})
|
|
if not schedule:
|
|
return flask.jsonify({"error": "no schedule set"}), 404
|
|
return flask.jsonify(schedule), 200
|
|
|
|
@app.route("/api/routines/<routine_id>/schedule", methods=["DELETE"])
|
|
def api_deleteRoutineSchedule(routine_id):
|
|
"""Remove the schedule from a routine."""
|
|
user_uuid = _auth(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
routine = postgres.select_one("routines", {"id": routine_id, "user_uuid": user_uuid})
|
|
if not routine:
|
|
return flask.jsonify({"error": "not found"}), 404
|
|
postgres.delete("routine_schedules", {"routine_id": routine_id})
|
|
return flask.jsonify({"deleted": True}), 200
|