- DB: tasks table with scheduled_datetime, reminder_minutes_before, advance_notified, status - API: CRUD routes GET/POST /api/tasks, PATCH/DELETE /api/tasks/<id> - Scheduler: check_task_reminders() fires advance + at-time notifications, tracks advance_notified to prevent double-fire - Bot: handle_task() with add/list/done/cancel/delete actions + datetime resolution helper - AI: task interaction type + examples added to command_parser - Web: task list page with overdue/notified color coding + new task form with datetime-local picker - Nav: replaced Templates with Tasks in bottom nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
245 lines
8.6 KiB
Python
245 lines
8.6 KiB
Python
"""
|
|
main.py - Flask API with auth routes and module registry
|
|
|
|
Domain routes are registered via the routes registry.
|
|
"""
|
|
|
|
import os
|
|
import flask
|
|
from flask_cors import CORS
|
|
import core.auth as auth
|
|
import core.users as users
|
|
import core.postgres as postgres
|
|
import api.routes.routines as routines_routes
|
|
import api.routes.medications as medications_routes
|
|
import api.routes.routine_steps_extended as routine_steps_extended_routes
|
|
import api.routes.routine_sessions_extended as routine_sessions_extended_routes
|
|
import api.routes.routine_templates as routine_templates_routes
|
|
import api.routes.routine_stats as routine_stats_routes
|
|
import api.routes.routine_tags as routine_tags_routes
|
|
import api.routes.notifications as notifications_routes
|
|
import api.routes.preferences as preferences_routes
|
|
import api.routes.rewards as rewards_routes
|
|
import api.routes.victories as victories_routes
|
|
import api.routes.adaptive_meds as adaptive_meds_routes
|
|
import api.routes.snitch as snitch_routes
|
|
import api.routes.ai as ai_routes
|
|
import api.routes.tasks as tasks_routes
|
|
|
|
app = flask.Flask(__name__)
|
|
CORS(app)
|
|
|
|
ROUTE_MODULES = [
|
|
routines_routes,
|
|
medications_routes,
|
|
routine_steps_extended_routes,
|
|
routine_sessions_extended_routes,
|
|
routine_templates_routes,
|
|
routine_stats_routes,
|
|
routine_tags_routes,
|
|
notifications_routes,
|
|
preferences_routes,
|
|
rewards_routes,
|
|
victories_routes,
|
|
adaptive_meds_routes,
|
|
snitch_routes,
|
|
ai_routes,
|
|
tasks_routes,
|
|
]
|
|
|
|
|
|
def register_routes(module):
|
|
"""Register a routes module. Module should have a register(app) function."""
|
|
ROUTE_MODULES.append(module)
|
|
|
|
|
|
# ── Auth Routes ────────────────────────────────────────────────────
|
|
|
|
|
|
@app.route("/api/register", methods=["POST"])
|
|
def api_register():
|
|
data = flask.request.get_json()
|
|
username = data.get("username")
|
|
password = data.get("password")
|
|
if not username or not password:
|
|
return flask.jsonify({"error": "username and password required"}), 400
|
|
result = users.registerUser(username, password, data)
|
|
if result:
|
|
return flask.jsonify({"success": True}), 201
|
|
else:
|
|
return flask.jsonify({"error": "username taken"}), 409
|
|
|
|
|
|
@app.route("/api/login", methods=["POST"])
|
|
def api_login():
|
|
data = flask.request.get_json()
|
|
username = data.get("username")
|
|
password = data.get("password")
|
|
if not username or not password:
|
|
return flask.jsonify({"error": "username and password required"}), 400
|
|
token = auth.getLoginToken(username, password)
|
|
if token:
|
|
response = {"token": token}
|
|
# Issue refresh token when trusted device is requested
|
|
if data.get("trust_device"):
|
|
import jwt as pyjwt
|
|
payload = pyjwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
|
|
user_uuid = payload.get("sub")
|
|
if user_uuid:
|
|
response["refresh_token"] = auth.createRefreshToken(user_uuid)
|
|
return flask.jsonify(response), 200
|
|
else:
|
|
return flask.jsonify({"error": "invalid credentials"}), 401
|
|
|
|
|
|
@app.route("/api/refresh", methods=["POST"])
|
|
def api_refresh():
|
|
"""Exchange a refresh token for a new access token."""
|
|
data = flask.request.get_json()
|
|
refresh_token = data.get("refresh_token") if data else None
|
|
if not refresh_token:
|
|
return flask.jsonify({"error": "refresh_token required"}), 400
|
|
access_token, user_uuid = auth.refreshAccessToken(refresh_token)
|
|
if access_token:
|
|
return flask.jsonify({"token": access_token}), 200
|
|
else:
|
|
return flask.jsonify({"error": "invalid or expired refresh token"}), 401
|
|
|
|
|
|
# ── User Routes ────────────────────────────────────────────────────
|
|
|
|
|
|
@app.route("/api/getUserUUID/<username>", methods=["GET"])
|
|
def api_getUserUUID(username):
|
|
header = flask.request.headers.get("Authorization", "")
|
|
if not header.startswith("Bearer "):
|
|
return flask.jsonify({"error": "missing token"}), 401
|
|
token = header[7:]
|
|
if auth.verifyLoginToken(token, username):
|
|
return flask.jsonify(users.getUserUUID(username)), 200
|
|
else:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
|
|
|
|
@app.route("/api/user/<userUUID>", methods=["GET"])
|
|
def api_getUser(userUUID):
|
|
header = flask.request.headers.get("Authorization", "")
|
|
if not header.startswith("Bearer "):
|
|
return flask.jsonify({"error": "missing token"}), 401
|
|
token = header[7:]
|
|
if auth.verifyLoginToken(token, userUUID=userUUID):
|
|
user = postgres.select_one("users", {"id": userUUID})
|
|
if user:
|
|
user.pop("password_hashed", None)
|
|
return flask.jsonify(user), 200
|
|
else:
|
|
return flask.jsonify({"error": "user not found"}), 404
|
|
else:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
|
|
|
|
@app.route("/api/user/<userUUID>", methods=["PUT"])
|
|
def api_updateUser(userUUID):
|
|
header = flask.request.headers.get("Authorization", "")
|
|
if not header.startswith("Bearer "):
|
|
return flask.jsonify({"error": "missing token"}), 401
|
|
token = header[7:]
|
|
if auth.verifyLoginToken(token, userUUID=userUUID):
|
|
data = flask.request.get_json()
|
|
result = users.updateUser(userUUID, data)
|
|
if result:
|
|
return flask.jsonify({"success": True}), 200
|
|
else:
|
|
return flask.jsonify({"error": "no valid fields to update"}), 400
|
|
else:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
|
|
|
|
@app.route("/api/user/<userUUID>", methods=["DELETE"])
|
|
def api_deleteUser(userUUID):
|
|
header = flask.request.headers.get("Authorization", "")
|
|
if not header.startswith("Bearer "):
|
|
return flask.jsonify({"error": "missing token"}), 401
|
|
token = header[7:]
|
|
if auth.verifyLoginToken(token, userUUID=userUUID):
|
|
data = flask.request.get_json()
|
|
password = data.get("password")
|
|
if not password:
|
|
return flask.jsonify(
|
|
{"error": "password required for account deletion"}
|
|
), 400
|
|
result = auth.unregisterUser(userUUID, password)
|
|
if result:
|
|
return flask.jsonify({"success": True}), 200
|
|
else:
|
|
return flask.jsonify({"error": "invalid password"}), 401
|
|
else:
|
|
return flask.jsonify({"error": "unauthorized"}), 401
|
|
|
|
|
|
# ── Health Check ───────────────────────────────────────────────────
|
|
|
|
|
|
@app.route("/health", methods=["GET"])
|
|
def health_check():
|
|
return flask.jsonify({"status": "ok"}), 200
|
|
|
|
|
|
def _seed_templates_if_empty():
|
|
"""Auto-seed routine templates if the table is empty."""
|
|
try:
|
|
count = postgres.count("routine_templates")
|
|
if count == 0:
|
|
import logging
|
|
|
|
logging.getLogger(__name__).info(
|
|
"No templates found, seeding from seed_templates.sql..."
|
|
)
|
|
seed_path = os.path.join(
|
|
os.path.dirname(__file__), "..", "config", "seed_templates.sql"
|
|
)
|
|
if os.path.exists(seed_path):
|
|
with open(seed_path, "r") as f:
|
|
sql = f.read()
|
|
with postgres.get_cursor() as cur:
|
|
cur.execute(sql)
|
|
logging.getLogger(__name__).info("Templates seeded successfully.")
|
|
except Exception as e:
|
|
import logging
|
|
|
|
logging.getLogger(__name__).warning(f"Failed to seed templates: {e}")
|
|
|
|
|
|
def _seed_rewards_if_empty():
|
|
"""Auto-seed reward pool if the table is empty."""
|
|
try:
|
|
count = postgres.count("reward_pool")
|
|
if count == 0:
|
|
import logging
|
|
|
|
logging.getLogger(__name__).info(
|
|
"No rewards found, seeding from seed_rewards.sql..."
|
|
)
|
|
seed_path = os.path.join(
|
|
os.path.dirname(__file__), "..", "config", "seed_rewards.sql"
|
|
)
|
|
if os.path.exists(seed_path):
|
|
with open(seed_path, "r") as f:
|
|
sql = f.read()
|
|
with postgres.get_cursor() as cur:
|
|
cur.execute(sql)
|
|
logging.getLogger(__name__).info("Rewards seeded successfully.")
|
|
except Exception as e:
|
|
import logging
|
|
|
|
logging.getLogger(__name__).warning(f"Failed to seed rewards: {e}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
for module in ROUTE_MODULES:
|
|
if hasattr(module, "register"):
|
|
module.register(app)
|
|
_seed_templates_if_empty()
|
|
_seed_rewards_if_empty()
|
|
app.run(host="0.0.0.0", port=5000)
|