diff --git a/api/main.py b/api/main.py index 848924e..c46efb6 100644 --- a/api/main.py +++ b/api/main.py @@ -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, ] diff --git a/api/routes/snitch.py b/api/routes/snitch.py new file mode 100644 index 0000000..aab7b7c --- /dev/null +++ b/api/routes/snitch.py @@ -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/", 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/", 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 diff --git a/config/schema.sql b/config/schema.sql index e118693..de51ea2 100644 --- a/config/schema.sql +++ b/config/schema.sql @@ -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); diff --git a/core/snitch.py b/core/snitch.py new file mode 100644 index 0000000..122e210 --- /dev/null +++ b/core/snitch.py @@ -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 diff --git a/scheduler/daemon.py b/scheduler/daemon.py index c36ebdb..0f93aa2 100644 --- a/scheduler/daemon.py +++ b/scheduler/daemon.py @@ -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}") diff --git a/synculous-client/src/lib/api.ts b/synculous-client/src/lib/api.ts index df376e7..2093b7e 100644 --- a/synculous-client/src/lib/api.ts +++ b/synculous-client/src/lib/api.ts @@ -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>('/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>(`/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 () => {