Both handlers were building update_data with defaults for every field, so a partial save (e.g. toggling one toggle) would silently reset all other settings back to their defaults. Now only fields explicitly present in the request body are written to the DB. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
299 lines
11 KiB
Python
299 lines
11 KiB
Python
"""
|
|
api/routes/snitch.py - API endpoints for snitch system
|
|
"""
|
|
|
|
import uuid
|
|
import flask
|
|
import jwt
|
|
import os
|
|
from datetime import datetime
|
|
import core.postgres as postgres
|
|
import core.snitch as snitch_core
|
|
|
|
JWT_SECRET = os.getenv("JWT_SECRET")
|
|
|
|
|
|
def _get_user_uuid(request):
|
|
"""Extract and validate user UUID from JWT token."""
|
|
auth_header = request.headers.get("Authorization", "")
|
|
if not auth_header.startswith("Bearer "):
|
|
return None
|
|
|
|
token = auth_header[7:]
|
|
try:
|
|
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
|
|
return payload.get("sub")
|
|
except jwt.ExpiredSignatureError:
|
|
return None
|
|
except jwt.InvalidTokenError:
|
|
return None
|
|
|
|
|
|
def register(app):
|
|
@app.route("/api/snitch/settings", methods=["GET"])
|
|
def get_snitch_settings():
|
|
"""Get user's snitch settings."""
|
|
user_uuid = _get_user_uuid(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "Unauthorized"}), 401
|
|
|
|
settings = snitch_core.get_snitch_settings(user_uuid)
|
|
|
|
if not settings:
|
|
# Return defaults
|
|
return flask.jsonify(
|
|
{
|
|
"snitch_enabled": False,
|
|
"trigger_after_nags": 4,
|
|
"trigger_after_missed_doses": 1,
|
|
"max_snitches_per_day": 2,
|
|
"require_consent": True,
|
|
"consent_given": False,
|
|
"snitch_cooldown_hours": 4,
|
|
}
|
|
), 200
|
|
|
|
return flask.jsonify(
|
|
{
|
|
"snitch_enabled": settings.get("snitch_enabled", False),
|
|
"trigger_after_nags": settings.get("trigger_after_nags", 4),
|
|
"trigger_after_missed_doses": settings.get(
|
|
"trigger_after_missed_doses", 1
|
|
),
|
|
"max_snitches_per_day": settings.get("max_snitches_per_day", 2),
|
|
"require_consent": settings.get("require_consent", True),
|
|
"consent_given": settings.get("consent_given", False),
|
|
"snitch_cooldown_hours": settings.get("snitch_cooldown_hours", 4),
|
|
}
|
|
), 200
|
|
|
|
@app.route("/api/snitch/settings", methods=["PUT"])
|
|
def update_snitch_settings():
|
|
"""Update user's snitch settings."""
|
|
user_uuid = _get_user_uuid(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "Unauthorized"}), 401
|
|
|
|
data = flask.request.get_json()
|
|
if not data:
|
|
return flask.jsonify({"error": "No data provided"}), 400
|
|
|
|
# Only update fields explicitly provided in the request — never overwrite with defaults
|
|
allowed_fields = [
|
|
"snitch_enabled", "trigger_after_nags", "trigger_after_missed_doses",
|
|
"max_snitches_per_day", "require_consent", "consent_given", "snitch_cooldown_hours",
|
|
]
|
|
update_data = {field: data[field] for field in allowed_fields if field in data}
|
|
|
|
if not update_data:
|
|
return flask.jsonify({"success": True}), 200
|
|
|
|
update_data["updated_at"] = datetime.utcnow()
|
|
|
|
existing = snitch_core.get_snitch_settings(user_uuid)
|
|
if existing:
|
|
postgres.update("snitch_settings", update_data, {"user_uuid": user_uuid})
|
|
else:
|
|
update_data["id"] = str(uuid.uuid4())
|
|
update_data["user_uuid"] = user_uuid
|
|
update_data["created_at"] = datetime.utcnow()
|
|
postgres.insert("snitch_settings", update_data)
|
|
|
|
return flask.jsonify({"success": True}), 200
|
|
|
|
@app.route("/api/snitch/consent", methods=["POST"])
|
|
def give_consent():
|
|
"""Give or revoke consent for snitching."""
|
|
user_uuid = _get_user_uuid(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "Unauthorized"}), 401
|
|
|
|
data = flask.request.get_json()
|
|
consent_given = data.get("consent_given", False)
|
|
|
|
snitch_core.update_consent(user_uuid, consent_given)
|
|
|
|
return flask.jsonify({"success": True, "consent_given": consent_given}), 200
|
|
|
|
@app.route("/api/snitch/contacts", methods=["GET"])
|
|
def get_snitch_contacts():
|
|
"""Get user's snitch contacts."""
|
|
user_uuid = _get_user_uuid(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "Unauthorized"}), 401
|
|
|
|
contacts = snitch_core.get_snitch_contacts(user_uuid, active_only=False)
|
|
|
|
return flask.jsonify(
|
|
[
|
|
{
|
|
"id": c.get("id"),
|
|
"contact_name": c.get("contact_name"),
|
|
"contact_type": c.get("contact_type"),
|
|
"contact_value": c.get("contact_value"),
|
|
"priority": c.get("priority", 1),
|
|
"notify_all": c.get("notify_all", False),
|
|
"is_active": c.get("is_active", True),
|
|
}
|
|
for c in contacts
|
|
]
|
|
), 200
|
|
|
|
@app.route("/api/snitch/contacts", methods=["POST"])
|
|
def add_snitch_contact():
|
|
"""Add a new snitch contact."""
|
|
user_uuid = _get_user_uuid(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "Unauthorized"}), 401
|
|
|
|
data = flask.request.get_json()
|
|
|
|
# Validate required fields
|
|
required = ["contact_name", "contact_type", "contact_value"]
|
|
for field in required:
|
|
if not data.get(field):
|
|
return flask.jsonify({"error": f"Missing required field: {field}"}), 400
|
|
|
|
# Validate contact_type
|
|
if data["contact_type"] not in ["discord", "email", "sms"]:
|
|
return flask.jsonify(
|
|
{"error": "contact_type must be discord, email, or sms"}
|
|
), 400
|
|
|
|
contact_data = {
|
|
"id": str(uuid.uuid4()),
|
|
"user_uuid": user_uuid,
|
|
"contact_name": data["contact_name"],
|
|
"contact_type": data["contact_type"],
|
|
"contact_value": data["contact_value"],
|
|
"priority": data.get("priority", 1),
|
|
"notify_all": data.get("notify_all", False),
|
|
"is_active": data.get("is_active", True),
|
|
"created_at": datetime.utcnow(),
|
|
}
|
|
|
|
result = postgres.insert("snitch_contacts", contact_data)
|
|
|
|
return flask.jsonify(
|
|
{"success": True, "contact_id": result.get("id") if result else None}
|
|
), 201
|
|
|
|
@app.route("/api/snitch/contacts/<contact_id>", methods=["PUT"])
|
|
def update_snitch_contact(contact_id):
|
|
"""Update a snitch contact."""
|
|
user_uuid = _get_user_uuid(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "Unauthorized"}), 401
|
|
|
|
data = flask.request.get_json()
|
|
|
|
# Check contact exists and belongs to user
|
|
contacts = postgres.select(
|
|
"snitch_contacts", {"id": contact_id, "user_uuid": user_uuid}
|
|
)
|
|
if not contacts:
|
|
return flask.jsonify({"error": "Contact not found"}), 404
|
|
|
|
update_data = {}
|
|
if "contact_name" in data:
|
|
update_data["contact_name"] = data["contact_name"]
|
|
if "contact_type" in data:
|
|
if data["contact_type"] not in ["discord", "email", "sms"]:
|
|
return flask.jsonify({"error": "Invalid contact_type"}), 400
|
|
update_data["contact_type"] = data["contact_type"]
|
|
if "contact_value" in data:
|
|
update_data["contact_value"] = data["contact_value"]
|
|
if "priority" in data:
|
|
update_data["priority"] = data["priority"]
|
|
if "notify_all" in data:
|
|
update_data["notify_all"] = data["notify_all"]
|
|
if "is_active" in data:
|
|
update_data["is_active"] = data["is_active"]
|
|
|
|
if update_data:
|
|
postgres.update("snitch_contacts", update_data, {"id": contact_id})
|
|
|
|
return flask.jsonify({"success": True}), 200
|
|
|
|
@app.route("/api/snitch/contacts/<contact_id>", methods=["DELETE"])
|
|
def delete_snitch_contact(contact_id):
|
|
"""Delete a snitch contact."""
|
|
user_uuid = _get_user_uuid(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "Unauthorized"}), 401
|
|
|
|
# Check contact exists and belongs to user
|
|
contacts = postgres.select(
|
|
"snitch_contacts", {"id": contact_id, "user_uuid": user_uuid}
|
|
)
|
|
if not contacts:
|
|
return flask.jsonify({"error": "Contact not found"}), 404
|
|
|
|
postgres.delete("snitch_contacts", {"id": contact_id})
|
|
|
|
return flask.jsonify({"success": True}), 200
|
|
|
|
@app.route("/api/snitch/history", methods=["GET"])
|
|
def get_snitch_history():
|
|
"""Get user's snitch history."""
|
|
user_uuid = _get_user_uuid(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "Unauthorized"}), 401
|
|
|
|
days = flask.request.args.get("days", 7, type=int)
|
|
history = snitch_core.get_snitch_history(user_uuid, days)
|
|
|
|
return flask.jsonify(
|
|
[
|
|
{
|
|
"id": h.get("id"),
|
|
"contact_id": h.get("contact_id"),
|
|
"medication_id": h.get("medication_id"),
|
|
"trigger_reason": h.get("trigger_reason"),
|
|
"snitch_count_today": h.get("snitch_count_today"),
|
|
"sent_at": h.get("sent_at").isoformat()
|
|
if h.get("sent_at")
|
|
else None,
|
|
"delivered": h.get("delivered"),
|
|
}
|
|
for h in history
|
|
]
|
|
), 200
|
|
|
|
@app.route("/api/snitch/test", methods=["POST"])
|
|
def test_snitch():
|
|
"""Test snitch functionality (sends to first contact only)."""
|
|
user_uuid = _get_user_uuid(flask.request)
|
|
if not user_uuid:
|
|
return flask.jsonify({"error": "Unauthorized"}), 401
|
|
|
|
# Get first active contact
|
|
contacts = snitch_core.get_snitch_contacts(user_uuid, active_only=True)
|
|
if not contacts:
|
|
return flask.jsonify({"error": "No active contacts configured"}), 400
|
|
|
|
# Send test message
|
|
contact = contacts[0]
|
|
test_message = f"🧪 This is a test snitch notification for {contact.get('contact_name')}. If you're receiving this, the snitch system is working!"
|
|
|
|
# Insert into snitch_log so the bot will pick it up and send it
|
|
log_data = {
|
|
"id": str(uuid.uuid4()),
|
|
"user_uuid": user_uuid,
|
|
"contact_id": contact.get("id"),
|
|
"medication_id": None, # Test snitch, no actual medication
|
|
"trigger_reason": "test",
|
|
"snitch_count_today": 1,
|
|
"message_content": test_message,
|
|
"sent_at": datetime.utcnow(),
|
|
"delivered": False, # Bot will pick this up and send it
|
|
}
|
|
postgres.insert("snitch_log", log_data)
|
|
|
|
return flask.jsonify(
|
|
{
|
|
"success": True,
|
|
"message": f"✅ Test snitch sent to {contact.get('contact_name')}! Check their Discord DMs in the next 30 seconds.",
|
|
}
|
|
), 200
|