Files
Synculous-2/api/routes/routines.py
chelsea 97a166f5aa Fix medication system and rename to Synculous.
- Add all 14 missing database tables (medications, med_logs, routines, etc.)
- Rewrite medication scheduling: support specific days, every N days, as-needed (PRN)
- Fix taken_times matching: match by created_at date, not scheduled_time string
- Fix adherence calculation: taken / expected doses, not taken / (taken + skipped)
- Add formatSchedule() helper for readable display
- Update client types and API layer
- Rename brilli-ins-client → synculous-client
- Make client PWA: add manifest, service worker, icons
- Bind dev server to 0.0.0.0 for network access
- Fix SVG icon bugs in Icons.tsx
- Add .dockerignore for client npm caching

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-13 03:23:38 -06:00

404 lines
19 KiB
Python

"""
Routines API - 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