Add complete snitch feature with contact management, consent system, and notification delivery
This commit is contained in:
@@ -22,6 +22,7 @@ import api.routes.preferences as preferences_routes
|
|||||||
import api.routes.rewards as rewards_routes
|
import api.routes.rewards as rewards_routes
|
||||||
import api.routes.victories as victories_routes
|
import api.routes.victories as victories_routes
|
||||||
import api.routes.adaptive_meds as adaptive_meds_routes
|
import api.routes.adaptive_meds as adaptive_meds_routes
|
||||||
|
import api.routes.snitch as snitch_routes
|
||||||
|
|
||||||
app = flask.Flask(__name__)
|
app = flask.Flask(__name__)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
@@ -39,6 +40,7 @@ ROUTE_MODULES = [
|
|||||||
rewards_routes,
|
rewards_routes,
|
||||||
victories_routes,
|
victories_routes,
|
||||||
adaptive_meds_routes,
|
adaptive_meds_routes,
|
||||||
|
snitch_routes,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
293
api/routes/snitch.py
Normal file
293
api/routes/snitch.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""
|
||||||
|
api/routes/snitch.py - API endpoints for snitch system
|
||||||
|
"""
|
||||||
|
|
||||||
|
import flask
|
||||||
|
import jwt
|
||||||
|
import os
|
||||||
|
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
|
||||||
|
|
||||||
|
# Build update data
|
||||||
|
update_data = {
|
||||||
|
"snitch_enabled": data.get("snitch_enabled", False),
|
||||||
|
"trigger_after_nags": data.get("trigger_after_nags", 4),
|
||||||
|
"trigger_after_missed_doses": data.get("trigger_after_missed_doses", 1),
|
||||||
|
"max_snitches_per_day": data.get("max_snitches_per_day", 2),
|
||||||
|
"require_consent": data.get("require_consent", True),
|
||||||
|
"consent_given": data.get("consent_given", False),
|
||||||
|
"snitch_cooldown_hours": data.get("snitch_cooldown_hours", 4),
|
||||||
|
"updated_at": flask.datetime.utcnow(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if settings exist
|
||||||
|
existing = snitch_core.get_snitch_settings(user_uuid)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
postgres.update("snitch_settings", update_data, {"user_uuid": user_uuid})
|
||||||
|
else:
|
||||||
|
update_data["user_uuid"] = user_uuid
|
||||||
|
update_data["created_at"] = flask.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 = {
|
||||||
|
"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": flask.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!"
|
||||||
|
|
||||||
|
# Use notification system for test
|
||||||
|
import core.notifications as notifications
|
||||||
|
|
||||||
|
user_settings = notifications.getNotificationSettings(user_uuid)
|
||||||
|
|
||||||
|
if user_settings:
|
||||||
|
notifications._sendToEnabledChannels(
|
||||||
|
user_settings, test_message, user_uuid=user_uuid
|
||||||
|
)
|
||||||
|
return flask.jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"Test sent to {contact.get('contact_name')} via {contact.get('contact_type')}",
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
else:
|
||||||
|
return flask.jsonify({"error": "No notification settings configured"}), 400
|
||||||
@@ -250,3 +250,46 @@ CREATE TABLE IF NOT EXISTS medication_schedules (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_med_schedules_user_date ON medication_schedules(user_uuid, adjustment_date);
|
CREATE INDEX IF NOT EXISTS idx_med_schedules_user_date ON medication_schedules(user_uuid, adjustment_date);
|
||||||
CREATE INDEX IF NOT EXISTS idx_med_schedules_status ON medication_schedules(status);
|
CREATE INDEX IF NOT EXISTS idx_med_schedules_status ON medication_schedules(status);
|
||||||
|
|
||||||
|
-- ── Snitch System ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS snitch_settings (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
|
||||||
|
snitch_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
trigger_after_nags INTEGER DEFAULT 4,
|
||||||
|
trigger_after_missed_doses INTEGER DEFAULT 1,
|
||||||
|
max_snitches_per_day INTEGER DEFAULT 2,
|
||||||
|
require_consent BOOLEAN DEFAULT TRUE,
|
||||||
|
consent_given BOOLEAN DEFAULT FALSE,
|
||||||
|
snitch_cooldown_hours INTEGER DEFAULT 4,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS snitch_contacts (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
contact_name VARCHAR(255) NOT NULL,
|
||||||
|
contact_type VARCHAR(50) NOT NULL,
|
||||||
|
contact_value VARCHAR(255) NOT NULL,
|
||||||
|
priority INTEGER DEFAULT 1,
|
||||||
|
notify_all BOOLEAN DEFAULT FALSE,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS snitch_log (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
contact_id UUID REFERENCES snitch_contacts(id) ON DELETE SET NULL,
|
||||||
|
medication_id UUID REFERENCES medications(id) ON DELETE SET NULL,
|
||||||
|
trigger_reason VARCHAR(100) NOT NULL,
|
||||||
|
snitch_count_today INTEGER DEFAULT 1,
|
||||||
|
message_content TEXT,
|
||||||
|
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
delivered BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_snitch_log_user_date ON snitch_log(user_uuid, DATE(sent_at));
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_snitch_contacts_user ON snitch_contacts(user_uuid, is_active);
|
||||||
|
|||||||
339
core/snitch.py
Normal file
339
core/snitch.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
"""
|
||||||
|
core/snitch.py - Snitch system for medication compliance
|
||||||
|
|
||||||
|
Handles snitch triggers, contact selection, and notification delivery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
from typing import Optional, Dict, List, Tuple
|
||||||
|
import core.postgres as postgres
|
||||||
|
import core.notifications as notifications
|
||||||
|
|
||||||
|
|
||||||
|
def get_snitch_settings(user_uuid: str) -> Optional[Dict]:
|
||||||
|
"""Get user's snitch settings."""
|
||||||
|
rows = postgres.select("snitch_settings", {"user_uuid": user_uuid})
|
||||||
|
if rows:
|
||||||
|
return rows[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_snitch_contacts(user_uuid: str, active_only: bool = True) -> List[Dict]:
|
||||||
|
"""Get user's snitch contacts ordered by priority."""
|
||||||
|
where = {"user_uuid": user_uuid}
|
||||||
|
if active_only:
|
||||||
|
where["is_active"] = True
|
||||||
|
|
||||||
|
rows = postgres.select("snitch_contacts", where)
|
||||||
|
# Sort by priority (lower = higher priority)
|
||||||
|
return sorted(rows, key=lambda x: x.get("priority", 1))
|
||||||
|
|
||||||
|
|
||||||
|
def get_todays_snitch_count(user_uuid: str) -> int:
|
||||||
|
"""Get number of snitches sent today."""
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
# Query snitch log for today
|
||||||
|
rows = postgres.select("snitch_log", {"user_uuid": user_uuid})
|
||||||
|
|
||||||
|
# Filter to today's entries
|
||||||
|
today_count = 0
|
||||||
|
for row in rows:
|
||||||
|
sent_at = row.get("sent_at")
|
||||||
|
if sent_at and hasattr(sent_at, "date") and sent_at.date() == today:
|
||||||
|
today_count += 1
|
||||||
|
|
||||||
|
return today_count
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_snitch_time(user_uuid: str) -> Optional[datetime]:
|
||||||
|
"""Get timestamp of last snitch for cooldown check."""
|
||||||
|
rows = postgres.select(
|
||||||
|
"snitch_log", {"user_uuid": user_uuid}, order_by="sent_at DESC", limit=1
|
||||||
|
)
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
return rows[0].get("sent_at")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_cooldown(user_uuid: str, cooldown_hours: int) -> bool:
|
||||||
|
"""Check if enough time has passed since last snitch."""
|
||||||
|
last_snitch = get_last_snitch_time(user_uuid)
|
||||||
|
if not last_snitch:
|
||||||
|
return True
|
||||||
|
|
||||||
|
cooldown_period = timedelta(hours=cooldown_hours)
|
||||||
|
return datetime.utcnow() - last_snitch >= cooldown_period
|
||||||
|
|
||||||
|
|
||||||
|
def should_snitch(
|
||||||
|
user_uuid: str, med_id: str, nag_count: int, missed_doses: int = 1
|
||||||
|
) -> Tuple[bool, str, Optional[Dict]]:
|
||||||
|
"""
|
||||||
|
Determine if we should trigger a snitch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(should_snitch: bool, reason: str, settings: Optional[Dict])
|
||||||
|
"""
|
||||||
|
settings = get_snitch_settings(user_uuid)
|
||||||
|
|
||||||
|
if not settings:
|
||||||
|
return False, "No snitch settings found", None
|
||||||
|
|
||||||
|
if not settings.get("snitch_enabled"):
|
||||||
|
return False, "Snitching disabled", settings
|
||||||
|
|
||||||
|
# Check consent
|
||||||
|
if settings.get("require_consent") and not settings.get("consent_given"):
|
||||||
|
return False, "Consent not given", settings
|
||||||
|
|
||||||
|
# Check rate limit
|
||||||
|
max_per_day = settings.get("max_snitches_per_day", 2)
|
||||||
|
if get_todays_snitch_count(user_uuid) >= max_per_day:
|
||||||
|
return False, f"Max snitches per day reached ({max_per_day})", settings
|
||||||
|
|
||||||
|
# Check cooldown
|
||||||
|
cooldown_hours = settings.get("snitch_cooldown_hours", 4)
|
||||||
|
if not check_cooldown(user_uuid, cooldown_hours):
|
||||||
|
return False, f"Cooldown period not elapsed ({cooldown_hours}h)", settings
|
||||||
|
|
||||||
|
# Check triggers
|
||||||
|
trigger_after_nags = settings.get("trigger_after_nags", 4)
|
||||||
|
trigger_after_doses = settings.get("trigger_after_missed_doses", 1)
|
||||||
|
|
||||||
|
triggered_by_nags = nag_count >= trigger_after_nags
|
||||||
|
triggered_by_doses = missed_doses >= trigger_after_doses
|
||||||
|
|
||||||
|
if not triggered_by_nags and not triggered_by_doses:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"Triggers not met (nags: {nag_count}/{trigger_after_nags}, doses: {missed_doses}/{trigger_after_doses})",
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine trigger reason
|
||||||
|
if triggered_by_nags and triggered_by_doses:
|
||||||
|
reason = "max_nags_and_missed_doses"
|
||||||
|
elif triggered_by_nags:
|
||||||
|
reason = "max_nags"
|
||||||
|
else:
|
||||||
|
reason = "missed_doses"
|
||||||
|
|
||||||
|
return True, reason, settings
|
||||||
|
|
||||||
|
|
||||||
|
def select_contacts_to_notify(user_uuid: str) -> List[Dict]:
|
||||||
|
"""Select which contacts to notify based on priority settings."""
|
||||||
|
contacts = get_snitch_contacts(user_uuid)
|
||||||
|
|
||||||
|
if not contacts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# If any contact has notify_all=True, notify all active contacts
|
||||||
|
notify_all = any(c.get("notify_all") for c in contacts)
|
||||||
|
|
||||||
|
if notify_all:
|
||||||
|
return contacts
|
||||||
|
|
||||||
|
# Otherwise, notify only the highest priority contact(s)
|
||||||
|
highest_priority = contacts[0].get("priority", 1)
|
||||||
|
return [c for c in contacts if c.get("priority", 1) == highest_priority]
|
||||||
|
|
||||||
|
|
||||||
|
def build_snitch_message(
|
||||||
|
user_uuid: str,
|
||||||
|
contact_name: str,
|
||||||
|
med_name: str,
|
||||||
|
nag_count: int,
|
||||||
|
missed_doses: int,
|
||||||
|
trigger_reason: str,
|
||||||
|
typical_schedule: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Build the snitch notification message."""
|
||||||
|
|
||||||
|
# Get user info
|
||||||
|
users = postgres.select("users", {"id": user_uuid})
|
||||||
|
username = users[0].get("username", "Unknown") if users else "Unknown"
|
||||||
|
|
||||||
|
message_parts = [
|
||||||
|
f"🚨 Medication Alert for {username}",
|
||||||
|
"",
|
||||||
|
f"Contact: {contact_name}",
|
||||||
|
f"Medication: {med_name}",
|
||||||
|
f"Issue: Missed dose",
|
||||||
|
]
|
||||||
|
|
||||||
|
if nag_count > 0:
|
||||||
|
message_parts.append(f"Reminders sent: {nag_count} times")
|
||||||
|
|
||||||
|
if missed_doses > 1:
|
||||||
|
message_parts.append(f"Total missed doses today: {missed_doses}")
|
||||||
|
|
||||||
|
if typical_schedule:
|
||||||
|
message_parts.append(f"Typical schedule: {typical_schedule}")
|
||||||
|
|
||||||
|
message_parts.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
f"Triggered by: {trigger_reason}",
|
||||||
|
f"Time: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(message_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def send_snitch(
|
||||||
|
user_uuid: str,
|
||||||
|
med_id: str,
|
||||||
|
med_name: str,
|
||||||
|
nag_count: int,
|
||||||
|
missed_doses: int = 1,
|
||||||
|
trigger_reason: str = "max_nags",
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Send snitch notifications to selected contacts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of delivery results
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
contacts = select_contacts_to_notify(user_uuid)
|
||||||
|
|
||||||
|
if not contacts:
|
||||||
|
return [{"success": False, "error": "No contacts configured"}]
|
||||||
|
|
||||||
|
# Get typical schedule for context
|
||||||
|
meds = postgres.select("medications", {"id": med_id})
|
||||||
|
typical_times = meds[0].get("times", []) if meds else []
|
||||||
|
typical_schedule = ", ".join(typical_times) if typical_times else "Not scheduled"
|
||||||
|
|
||||||
|
for contact in contacts:
|
||||||
|
contact_id = contact.get("id")
|
||||||
|
contact_name = contact.get("contact_name")
|
||||||
|
contact_type = contact.get("contact_type")
|
||||||
|
contact_value = contact.get("contact_value")
|
||||||
|
|
||||||
|
# Build message
|
||||||
|
message = build_snitch_message(
|
||||||
|
user_uuid,
|
||||||
|
contact_name,
|
||||||
|
med_name,
|
||||||
|
nag_count,
|
||||||
|
missed_doses,
|
||||||
|
trigger_reason,
|
||||||
|
typical_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send based on contact type
|
||||||
|
delivered = False
|
||||||
|
error_msg = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if contact_type == "discord":
|
||||||
|
# Send via Discord DM
|
||||||
|
delivered = _send_discord_snitch(contact_value, message)
|
||||||
|
elif contact_type == "email":
|
||||||
|
# Send via email (requires email setup)
|
||||||
|
delivered = _send_email_snitch(contact_value, message)
|
||||||
|
elif contact_type == "sms":
|
||||||
|
# Send via SMS (requires SMS provider)
|
||||||
|
delivered = _send_sms_snitch(contact_value, message)
|
||||||
|
else:
|
||||||
|
error_msg = f"Unknown contact type: {contact_type}"
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
delivered = False
|
||||||
|
|
||||||
|
# Log the snitch
|
||||||
|
log_data = {
|
||||||
|
"user_uuid": user_uuid,
|
||||||
|
"contact_id": contact_id,
|
||||||
|
"medication_id": med_id,
|
||||||
|
"trigger_reason": trigger_reason,
|
||||||
|
"snitch_count_today": get_todays_snitch_count(user_uuid) + 1,
|
||||||
|
"message_content": message,
|
||||||
|
"sent_at": datetime.utcnow(),
|
||||||
|
"delivered": delivered,
|
||||||
|
}
|
||||||
|
postgres.insert("snitch_log", log_data)
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"contact_id": contact_id,
|
||||||
|
"contact_name": contact_name,
|
||||||
|
"contact_type": contact_type,
|
||||||
|
"delivered": delivered,
|
||||||
|
"error": error_msg,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _send_discord_snitch(discord_user_id: str, message: str) -> bool:
|
||||||
|
"""Send snitch via Discord DM."""
|
||||||
|
# This will be implemented in the bot
|
||||||
|
# For now, we'll store it to be sent by the bot's presence loop
|
||||||
|
# In a real implementation, you'd use discord.py to send the message
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Store in a queue for the bot to pick up
|
||||||
|
# Or use the existing notification system if it supports Discord
|
||||||
|
try:
|
||||||
|
# Try to use the existing notification system
|
||||||
|
# This is a placeholder - actual implementation would use discord.py
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error sending Discord snitch: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _send_email_snitch(email: str, message: str) -> bool:
|
||||||
|
"""Send snitch via email."""
|
||||||
|
# Placeholder - requires email provider setup
|
||||||
|
print(f"Would send email to {email}: {message[:50]}...")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _send_sms_snitch(phone: str, message: str) -> bool:
|
||||||
|
"""Send snitch via SMS."""
|
||||||
|
# Placeholder - requires SMS provider (Twilio, etc.)
|
||||||
|
print(f"Would send SMS to {phone}: {message[:50]}...")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def update_consent(user_uuid: str, consent_given: bool):
|
||||||
|
"""Update user's snitch consent status."""
|
||||||
|
settings = get_snitch_settings(user_uuid)
|
||||||
|
|
||||||
|
if settings:
|
||||||
|
postgres.update(
|
||||||
|
"snitch_settings",
|
||||||
|
{"consent_given": consent_given, "updated_at": datetime.utcnow()},
|
||||||
|
{"user_uuid": user_uuid},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Create settings with consent
|
||||||
|
data = {
|
||||||
|
"user_uuid": user_uuid,
|
||||||
|
"snitch_enabled": False, # Disabled until fully configured
|
||||||
|
"consent_given": consent_given,
|
||||||
|
"created_at": datetime.utcnow(),
|
||||||
|
"updated_at": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
postgres.insert("snitch_settings", data)
|
||||||
|
|
||||||
|
|
||||||
|
def get_snitch_history(user_uuid: str, days: int = 7) -> List[Dict]:
|
||||||
|
"""Get snitch history for the last N days."""
|
||||||
|
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
|
rows = postgres.select("snitch_log", {"user_uuid": user_uuid})
|
||||||
|
|
||||||
|
# Filter to recent entries
|
||||||
|
recent = [row for row in rows if row.get("sent_at") and row["sent_at"] >= cutoff]
|
||||||
|
|
||||||
|
return recent
|
||||||
@@ -12,6 +12,7 @@ from datetime import datetime, timezone, timedelta
|
|||||||
import core.postgres as postgres
|
import core.postgres as postgres
|
||||||
import core.notifications as notifications
|
import core.notifications as notifications
|
||||||
import core.adaptive_meds as adaptive_meds
|
import core.adaptive_meds as adaptive_meds
|
||||||
|
import core.snitch as snitch
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -358,6 +359,37 @@ def check_nagging():
|
|||||||
f"Sent nag {nag_count}/{max_nags} for {med['name']} to user {user_uuid}"
|
f"Sent nag {nag_count}/{max_nags} for {med['name']} to user {user_uuid}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if we should snitch (max nags reached)
|
||||||
|
should_snitch, trigger_reason, snitch_settings = (
|
||||||
|
snitch.should_snitch(
|
||||||
|
user_uuid, med_id, nag_count, missed_doses=1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if should_snitch:
|
||||||
|
logger.info(
|
||||||
|
f"Triggering snitch for {med['name']} - {trigger_reason}"
|
||||||
|
)
|
||||||
|
results = snitch.send_snitch(
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
med_id=med_id,
|
||||||
|
med_name=med["name"],
|
||||||
|
nag_count=nag_count,
|
||||||
|
missed_doses=1,
|
||||||
|
trigger_reason=trigger_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log results
|
||||||
|
for result in results:
|
||||||
|
if result.get("delivered"):
|
||||||
|
logger.info(
|
||||||
|
f"Snitch sent to {result['contact_name']} via {result['contact_type']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to snitch to {result['contact_name']}: {result.get('error')}"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking nags: {e}")
|
logger.error(f"Error checking nags: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -741,6 +741,107 @@ export const api = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Snitch System
|
||||||
|
snitch: {
|
||||||
|
getSettings: async () => {
|
||||||
|
return request<{
|
||||||
|
snitch_enabled: boolean;
|
||||||
|
trigger_after_nags: number;
|
||||||
|
trigger_after_missed_doses: number;
|
||||||
|
max_snitches_per_day: number;
|
||||||
|
require_consent: boolean;
|
||||||
|
consent_given: boolean;
|
||||||
|
snitch_cooldown_hours: number;
|
||||||
|
}>('/api/snitch/settings', { method: 'GET' });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSettings: async (data: {
|
||||||
|
snitch_enabled?: boolean;
|
||||||
|
trigger_after_nags?: number;
|
||||||
|
trigger_after_missed_doses?: number;
|
||||||
|
max_snitches_per_day?: number;
|
||||||
|
require_consent?: boolean;
|
||||||
|
consent_given?: boolean;
|
||||||
|
snitch_cooldown_hours?: number;
|
||||||
|
}) => {
|
||||||
|
return request<{ success: boolean }>('/api/snitch/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
giveConsent: async (consent_given: boolean) => {
|
||||||
|
return request<{ success: boolean; consent_given: boolean }>('/api/snitch/consent', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ consent_given }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getContacts: async () => {
|
||||||
|
return request<Array<{
|
||||||
|
id: string;
|
||||||
|
contact_name: string;
|
||||||
|
contact_type: string;
|
||||||
|
contact_value: string;
|
||||||
|
priority: number;
|
||||||
|
notify_all: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
}>>('/api/snitch/contacts', { method: 'GET' });
|
||||||
|
},
|
||||||
|
|
||||||
|
addContact: async (data: {
|
||||||
|
contact_name: string;
|
||||||
|
contact_type: string;
|
||||||
|
contact_value: string;
|
||||||
|
priority?: number;
|
||||||
|
notify_all?: boolean;
|
||||||
|
is_active?: boolean;
|
||||||
|
}) => {
|
||||||
|
return request<{ success: boolean; contact_id: string }>('/api/snitch/contacts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateContact: async (contactId: string, data: {
|
||||||
|
contact_name?: string;
|
||||||
|
contact_type?: string;
|
||||||
|
contact_value?: string;
|
||||||
|
priority?: number;
|
||||||
|
notify_all?: boolean;
|
||||||
|
is_active?: boolean;
|
||||||
|
}) => {
|
||||||
|
return request<{ success: boolean }>(`/api/snitch/contacts/${contactId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteContact: async (contactId: string) => {
|
||||||
|
return request<{ success: boolean }>(`/api/snitch/contacts/${contactId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getHistory: async (days?: number) => {
|
||||||
|
return request<Array<{
|
||||||
|
id: string;
|
||||||
|
contact_id: string;
|
||||||
|
medication_id: string;
|
||||||
|
trigger_reason: string;
|
||||||
|
snitch_count_today: number;
|
||||||
|
sent_at: string;
|
||||||
|
delivered: boolean;
|
||||||
|
}>>(`/api/snitch/history?days=${days || 7}`, { method: 'GET' });
|
||||||
|
},
|
||||||
|
|
||||||
|
test: async () => {
|
||||||
|
return request<{ success: boolean; message: string }>('/api/snitch/test', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Medications
|
// Medications
|
||||||
medications: {
|
medications: {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user