Add complete snitch feature with contact management, consent system, and notification delivery

This commit is contained in:
2026-02-16 20:14:03 -06:00
parent 69163a37d1
commit a6ae4e13fd
6 changed files with 810 additions and 0 deletions

View File

@@ -22,6 +22,7 @@ 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
app = flask.Flask(__name__)
CORS(app)
@@ -39,6 +40,7 @@ ROUTE_MODULES = [
rewards_routes,
victories_routes,
adaptive_meds_routes,
snitch_routes,
]

293
api/routes/snitch.py Normal file
View 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

View File

@@ -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_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
View 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

View File

@@ -12,6 +12,7 @@ from datetime import datetime, timezone, timedelta
import core.postgres as postgres
import core.notifications as notifications
import core.adaptive_meds as adaptive_meds
import core.snitch as snitch
logging.basicConfig(level=logging.INFO)
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}"
)
# 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:
logger.error(f"Error checking nags: {e}")

View File

@@ -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: {
list: async () => {