Files
Synculous-2/api/routes/snitch.py
chelsea 6850abf7d2 fix partial-update overwrite bug in snitch and adaptive meds PUT handlers
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>
2026-02-17 19:05:09 -06:00

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