Fix issues #6, #7, #11, #12, #13: med reminders, push notifications, auth persistence, scheduling conflicts

- Fix TIME object vs string comparison in scheduler preventing adaptive med
  reminders from ever firing (#12, #6)
- Add frequency filtering to midnight schedule creation for every_n_days meds
- Require start_date and interval_days for every_n_days medications
- Add refresh token support (30-day) to API and bot for persistent sessions (#13)
- Add "trusted device" checkbox to frontend login for long-lived sessions (#7)
- Auto-refresh expired tokens in both bot (apiRequest) and frontend (api.ts)
- Restore bot sessions from cache on restart using refresh tokens
- Duration-aware routine scheduling conflict detection (#11)
- Add conflict check when starting routine sessions against medication times
- Add diagnostic logging to notification delivery channels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 13:05:48 -06:00
parent 6850abf7d2
commit d4adbde3df
10 changed files with 474 additions and 69 deletions

View File

@@ -75,11 +75,33 @@ def api_login():
return flask.jsonify({"error": "username and password required"}), 400 return flask.jsonify({"error": "username and password required"}), 400
token = auth.getLoginToken(username, password) token = auth.getLoginToken(username, password)
if token: if token:
return flask.jsonify({"token": token}), 200 response = {"token": token}
# Issue refresh token when trusted device is requested
if data.get("trust_device"):
import jwt as pyjwt
payload = pyjwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
user_uuid = payload.get("sub")
if user_uuid:
response["refresh_token"] = auth.createRefreshToken(user_uuid)
return flask.jsonify(response), 200
else: else:
return flask.jsonify({"error": "invalid credentials"}), 401 return flask.jsonify({"error": "invalid credentials"}), 401
@app.route("/api/refresh", methods=["POST"])
def api_refresh():
"""Exchange a refresh token for a new access token."""
data = flask.request.get_json()
refresh_token = data.get("refresh_token") if data else None
if not refresh_token:
return flask.jsonify({"error": "refresh_token required"}), 400
access_token, user_uuid = auth.refreshAccessToken(refresh_token)
if access_token:
return flask.jsonify({"token": access_token}), 200
else:
return flask.jsonify({"error": "invalid or expired refresh token"}), 401
# ── User Routes ──────────────────────────────────────────────────── # ── User Routes ────────────────────────────────────────────────────

View File

@@ -145,6 +145,17 @@ def register(app):
meds = postgres.select("medications", where={"user_uuid": user_uuid}, order_by="name") meds = postgres.select("medications", where={"user_uuid": user_uuid}, order_by="name")
return flask.jsonify(meds), 200 return flask.jsonify(meds), 200
def _time_str_to_minutes(time_str):
"""Convert 'HH:MM' to minutes since midnight."""
parts = time_str.split(":")
return int(parts[0]) * 60 + int(parts[1])
def _get_routine_duration_minutes(routine_id):
"""Get total duration of a routine from its steps."""
steps = postgres.select("routine_steps", where={"routine_id": routine_id})
total = sum(s.get("duration_minutes", 0) or 0 for s in steps)
return max(total, 1)
def _check_med_schedule_conflicts(user_uuid, new_times, new_days=None, exclude_med_id=None): def _check_med_schedule_conflicts(user_uuid, new_times, new_days=None, exclude_med_id=None):
"""Check if the proposed medication schedule conflicts with existing routines or medications. """Check if the proposed medication schedule conflicts with existing routines or medications.
Returns (has_conflict, conflict_message) tuple. Returns (has_conflict, conflict_message) tuple.
@@ -152,13 +163,23 @@ def register(app):
if not new_times: if not new_times:
return False, None return False, None
# Check conflicts with routines # Check conflicts with routines (duration-aware)
user_routines = postgres.select("routines", {"user_uuid": user_uuid}) user_routines = postgres.select("routines", {"user_uuid": user_uuid})
for r in user_routines: for r in user_routines:
sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]}) sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]})
if sched and sched.get("time") in new_times: if not sched or not sched.get("time"):
routine_days = json.loads(sched.get("days", "[]")) continue
if not new_days or any(d in routine_days for d in new_days): routine_days = sched.get("days", [])
if isinstance(routine_days, str):
routine_days = json.loads(routine_days)
if new_days and not any(d in routine_days for d in new_days):
continue
routine_start = _time_str_to_minutes(sched["time"])
routine_dur = _get_routine_duration_minutes(r["id"])
for t in new_times:
med_start = _time_str_to_minutes(t)
# Med falls within routine time range
if routine_start <= med_start < routine_start + routine_dur:
return True, f"Time conflicts with routine: {r.get('name', 'Unnamed routine')}" return True, f"Time conflicts with routine: {r.get('name', 'Unnamed routine')}"
# Check conflicts with other medications # Check conflicts with other medications
@@ -188,6 +209,11 @@ def register(app):
if missing: if missing:
return flask.jsonify({"error": f"missing required fields: {', '.join(missing)}"}), 400 return flask.jsonify({"error": f"missing required fields: {', '.join(missing)}"}), 400
# Validate every_n_days required fields
if data.get("frequency") == "every_n_days":
if not data.get("start_date") or not data.get("interval_days"):
return flask.jsonify({"error": "every_n_days frequency requires both start_date and interval_days"}), 400
# Check for schedule conflicts # Check for schedule conflicts
new_times = data.get("times", []) new_times = data.get("times", [])
new_days = data.get("days_of_week", []) new_days = data.get("days_of_week", [])

View File

@@ -7,7 +7,7 @@ Routines have ordered steps. Users start sessions to walk through them.
import os import os
import uuid import uuid
import json import json
from datetime import datetime from datetime import datetime, timedelta
import flask import flask
import jwt import jwt
import core.auth as auth import core.auth as auth
@@ -420,6 +420,31 @@ def register(app):
return flask.jsonify( return flask.jsonify(
{"error": "already have active session", "session_id": active["id"]} {"error": "already have active session", "session_id": active["id"]}
), 409 ), 409
# Check if starting now would conflict with medication times
now = tz.user_now()
current_time = now.strftime("%H:%M")
current_day = now.strftime("%a").lower()
routine_dur = _get_routine_duration_minutes(routine_id)
routine_start = _time_str_to_minutes(current_time)
user_meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
for med in user_meds:
med_times = med.get("times", [])
if isinstance(med_times, str):
med_times = json.loads(med_times)
med_days = med.get("days_of_week", [])
if isinstance(med_days, str):
med_days = json.loads(med_days)
if med_days and current_day not in med_days:
continue
for mt in med_times:
med_start = _time_str_to_minutes(mt)
if _ranges_overlap(routine_start, routine_dur, med_start, 1):
return flask.jsonify(
{"error": f"Starting now would conflict with {med.get('name', 'medication')} at {mt}"}
), 409
steps = postgres.select( steps = postgres.select(
"routine_steps", "routine_steps",
where={"routine_id": routine_id}, where={"routine_id": routine_id},
@@ -649,22 +674,53 @@ def register(app):
) )
return flask.jsonify(result), 200 return flask.jsonify(result), 200
def _check_schedule_conflicts(user_uuid, new_days, new_time, exclude_routine_id=None): def _get_routine_duration_minutes(routine_id):
"""Get total duration of a routine from its steps."""
steps = postgres.select("routine_steps", where={"routine_id": routine_id})
total = sum(s.get("duration_minutes", 0) or 0 for s in steps)
return max(total, 1) # At least 1 minute
def _time_str_to_minutes(time_str):
"""Convert 'HH:MM' to minutes since midnight."""
parts = time_str.split(":")
return int(parts[0]) * 60 + int(parts[1])
def _ranges_overlap(start1, dur1, start2, dur2):
"""Check if two time ranges overlap (in minutes since midnight)."""
end1 = start1 + dur1
end2 = start2 + dur2
return start1 < end2 and start2 < end1
def _check_schedule_conflicts(user_uuid, new_days, new_time, exclude_routine_id=None, new_routine_id=None):
"""Check if the proposed schedule conflicts with existing routines or medications. """Check if the proposed schedule conflicts with existing routines or medications.
Returns (has_conflict, conflict_message) tuple. Returns (has_conflict, conflict_message) tuple.
""" """
if not new_days or not new_time: if not new_days or not new_time:
return False, None return False, None
new_start = _time_str_to_minutes(new_time)
# Get duration of the routine being scheduled
if new_routine_id:
new_dur = _get_routine_duration_minutes(new_routine_id)
else:
new_dur = 1
# Check conflicts with other routines # Check conflicts with other routines
user_routines = postgres.select("routines", {"user_uuid": user_uuid}) user_routines = postgres.select("routines", {"user_uuid": user_uuid})
for r in user_routines: for r in user_routines:
if r["id"] == exclude_routine_id: if r["id"] == exclude_routine_id:
continue continue
other_sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]}) other_sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]})
if other_sched and other_sched.get("time") == new_time: if not other_sched or not other_sched.get("time"):
other_days = json.loads(other_sched.get("days", "[]")) continue
if any(d in other_days for d in new_days): other_days = other_sched.get("days", [])
if isinstance(other_days, str):
other_days = json.loads(other_days)
if not any(d in other_days for d in new_days):
continue
other_start = _time_str_to_minutes(other_sched["time"])
other_dur = _get_routine_duration_minutes(r["id"])
if _ranges_overlap(new_start, new_dur, other_start, other_dur):
return True, f"Time conflicts with routine: {r.get('name', 'Unnamed routine')}" return True, f"Time conflicts with routine: {r.get('name', 'Unnamed routine')}"
# Check conflicts with medications # Check conflicts with medications
@@ -673,12 +729,16 @@ def register(app):
med_times = med.get("times", []) med_times = med.get("times", [])
if isinstance(med_times, str): if isinstance(med_times, str):
med_times = json.loads(med_times) med_times = json.loads(med_times)
if new_time in med_times:
# Check if medication runs on any of the same days
med_days = med.get("days_of_week", []) med_days = med.get("days_of_week", [])
if isinstance(med_days, str): if isinstance(med_days, str):
med_days = json.loads(med_days) med_days = json.loads(med_days)
if not med_days or any(d in med_days for d in new_days): # If med has no specific days, it runs every day
if med_days and not any(d in med_days for d in new_days):
continue
for mt in med_times:
med_start = _time_str_to_minutes(mt)
# Medication takes ~0 minutes, but check if it falls within routine window
if _ranges_overlap(new_start, new_dur, med_start, 1):
return True, f"Time conflicts with medication: {med.get('name', 'Unnamed medication')}" return True, f"Time conflicts with medication: {med.get('name', 'Unnamed medication')}"
return False, None return False, None
@@ -702,7 +762,8 @@ def register(app):
new_days = data.get("days", []) new_days = data.get("days", [])
new_time = data.get("time") new_time = data.get("time")
has_conflict, conflict_msg = _check_schedule_conflicts( has_conflict, conflict_msg = _check_schedule_conflicts(
user_uuid, new_days, new_time, exclude_routine_id=routine_id user_uuid, new_days, new_time, exclude_routine_id=routine_id,
new_routine_id=routine_id,
) )
if has_conflict: if has_conflict:
return flask.jsonify({"error": conflict_msg}), 409 return flask.jsonify({"error": conflict_msg}), 409

View File

@@ -116,21 +116,26 @@ class JurySystem:
print(f"Error loading DBT knowledge base: {e}") print(f"Error loading DBT knowledge base: {e}")
raise raise
async def query(self, query_text): def _retrieve_sync(self, query_text, top_k=5):
"""Query the DBT knowledge base""" """Embed query and search vector store. Returns list of chunk dicts."""
try:
# Get embedding
response = self.client.embeddings.create( response = self.client.embeddings.create(
model="qwen/qwen3-embedding-8b", input=query_text model="qwen/qwen3-embedding-8b", input=query_text
) )
query_emb = response.data[0].embedding query_emb = response.data[0].embedding
return self.vector_store.search(query_emb, top_k=top_k)
# Search async def retrieve(self, query_text, top_k=5):
context_chunks = self.vector_store.search(query_emb, top_k=5) """Async retrieval — returns list of {metadata, score} dicts."""
import asyncio
return await asyncio.to_thread(self._retrieve_sync, query_text, top_k)
async def query(self, query_text):
"""Query the DBT knowledge base (legacy path, kept for compatibility)."""
try:
context_chunks = await self.retrieve(query_text)
if not context_chunks: if not context_chunks:
return "I couldn't find relevant DBT information for that query." return "I couldn't find relevant DBT information for that query."
# Generate answer
context_text = "\n\n---\n\n".join( context_text = "\n\n---\n\n".join(
[chunk["metadata"]["text"] for chunk in context_chunks] [chunk["metadata"]["text"] for chunk in context_chunks]
) )
@@ -140,20 +145,8 @@ Use the provided context from the DBT Skills Training Handouts to answer the use
If the answer is not in the context, say you don't know based on the provided text. If the answer is not in the context, say you don't know based on the provided text.
Be concise, compassionate, and practical.""" Be concise, compassionate, and practical."""
user_prompt = f"Context:\n{context_text}\n\nQuestion: {query_text}" from ai.jury_council import generate_rag_answer
return await generate_rag_answer(query_text, context_text, system_prompt)
response = self.client.chat.completions.create(
model=self.config.get("models", {}).get(
"generator", "openai/gpt-4o-mini"
),
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.7,
)
return response.choices[0].message.content
except Exception as e: except Exception as e:
return f"Error querying DBT knowledge base: {e}" return f"Error querying DBT knowledge base: {e}"
@@ -176,13 +169,18 @@ def decodeJwtPayload(token):
return json.loads(base64.urlsafe_b64decode(payload)) return json.loads(base64.urlsafe_b64decode(payload))
def apiRequest(method, endpoint, token=None, data=None): def apiRequest(method, endpoint, token=None, data=None, _retried=False):
url = f"{API_URL}{endpoint}" url = f"{API_URL}{endpoint}"
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
if token: if token:
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
try: try:
resp = getattr(requests, method)(url, headers=headers, json=data, timeout=10) resp = getattr(requests, method)(url, headers=headers, json=data, timeout=10)
# Auto-refresh on 401 using refresh token
if resp.status_code == 401 and not _retried:
new_token = _try_refresh_token_for_session(token)
if new_token:
return apiRequest(method, endpoint, token=new_token, data=data, _retried=True)
try: try:
return resp.json(), resp.status_code return resp.json(), resp.status_code
except ValueError: except ValueError:
@@ -191,6 +189,31 @@ def apiRequest(method, endpoint, token=None, data=None):
return {"error": "API unavailable"}, 503 return {"error": "API unavailable"}, 503
def _try_refresh_token_for_session(expired_token):
"""Find the discord user with this token and refresh it using their refresh token."""
for discord_id, session in user_sessions.items():
if session.get("token") == expired_token:
refresh_token = session.get("refresh_token")
if not refresh_token:
# Check cache for refresh token
cached = getCachedUser(discord_id)
if cached:
refresh_token = cached.get("refresh_token")
if refresh_token:
result, status = apiRequest("post", "/api/refresh",
data={"refresh_token": refresh_token},
_retried=True)
if status == 200 and "token" in result:
new_token = result["token"]
session["token"] = new_token
# Update cache
cached = getCachedUser(discord_id) or {}
cached["refresh_token"] = refresh_token
setCachedUser(discord_id, cached)
return new_token
return None
def loadCache(): def loadCache():
try: try:
if os.path.exists(CACHE_FILE): if os.path.exists(CACHE_FILE):
@@ -229,14 +252,32 @@ def setCachedUser(discord_id, user_data):
def negotiateToken(discord_id, username, password): def negotiateToken(discord_id, username, password):
cached = getCachedUser(discord_id) cached = getCachedUser(discord_id)
# Try refresh token first (avoids sending password)
if cached and cached.get("refresh_token"):
result, status = apiRequest(
"post", "/api/refresh",
data={"refresh_token": cached["refresh_token"]},
_retried=True,
)
if status == 200 and "token" in result:
token = result["token"]
payload = decodeJwtPayload(token)
user_uuid = payload["sub"]
cached["user_uuid"] = user_uuid
setCachedUser(discord_id, cached)
return token, user_uuid
# Fall back to password login, always request refresh token (trust_device)
login_data = {"username": username, "password": password, "trust_device": True}
if ( if (
cached cached
and cached.get("username") == username and cached.get("username") == username
and cached.get("hashed_password")
and verifyPassword(password, cached.get("hashed_password")) and verifyPassword(password, cached.get("hashed_password"))
): ):
result, status = apiRequest( result, status = apiRequest("post", "/api/login", data=login_data, _retried=True)
"post", "/api/login", data={"username": username, "password": password}
)
if status == 200 and "token" in result: if status == 200 and "token" in result:
token = result["token"] token = result["token"]
payload = decodeJwtPayload(token) payload = decodeJwtPayload(token)
@@ -247,14 +288,13 @@ def negotiateToken(discord_id, username, password):
"hashed_password": cached["hashed_password"], "hashed_password": cached["hashed_password"],
"user_uuid": user_uuid, "user_uuid": user_uuid,
"username": username, "username": username,
"refresh_token": result.get("refresh_token"),
}, },
) )
return token, user_uuid return token, user_uuid
return None, None return None, None
result, status = apiRequest( result, status = apiRequest("post", "/api/login", data=login_data, _retried=True)
"post", "/api/login", data={"username": username, "password": password}
)
if status == 200 and "token" in result: if status == 200 and "token" in result:
token = result["token"] token = result["token"]
payload = decodeJwtPayload(token) payload = decodeJwtPayload(token)
@@ -265,6 +305,7 @@ def negotiateToken(discord_id, username, password):
"hashed_password": hashPassword(password), "hashed_password": hashPassword(password),
"user_uuid": user_uuid, "user_uuid": user_uuid,
"username": username, "username": username,
"refresh_token": result.get("refresh_token"),
}, },
) )
return token, user_uuid return token, user_uuid
@@ -428,7 +469,7 @@ async def handleActiveSessionShortcuts(message, session, active_session):
async def handleDBTQuery(message): async def handleDBTQuery(message):
"""Handle DBT-related queries using JurySystem""" """Handle DBT-related queries using JurySystem + jury council pipeline."""
if not jury_system: if not jury_system:
return False return False
@@ -456,13 +497,66 @@ async def handleDBTQuery(message):
user_input_lower = message.content.lower() user_input_lower = message.content.lower()
is_dbt_query = any(keyword in user_input_lower for keyword in dbt_keywords) is_dbt_query = any(keyword in user_input_lower for keyword in dbt_keywords)
if is_dbt_query: if not is_dbt_query:
return False
from ai.jury_council import (
generate_search_questions,
run_jury_filter,
generate_rag_answer,
split_for_discord,
)
async with message.channel.typing(): async with message.channel.typing():
response = await jury_system.query(message.content) # Step 1: Generate candidate questions via Qwen Nitro (fallback: qwen3-235b)
await message.channel.send(f"🧠 **DBT Support:**\n{response}") candidates, gen_error = await generate_search_questions(message.content)
if gen_error:
await message.channel.send(f"⚠️ **Question generator failed:** {gen_error}")
return True return True
return False # Step 2: Jury council filters candidates → safe question JSON list
jury_result = await run_jury_filter(candidates, message.content)
breakdown = jury_result.format_breakdown()
# Always show the jury deliberation (verbose, as requested)
for chunk in split_for_discord(breakdown):
await message.channel.send(chunk)
if jury_result.has_error:
return True
if not jury_result.safe_questions:
return True
await message.channel.send("🔍 Searching knowledge base with approved questions...")
# Step 3: Multi-query retrieval — deduplicated by chunk ID
seen_ids = set()
context_chunks = []
for q in jury_result.safe_questions:
results = await jury_system.retrieve(q)
for r in results:
chunk_id = r["metadata"].get("id")
if chunk_id not in seen_ids:
seen_ids.add(chunk_id)
context_chunks.append(r["metadata"]["text"])
if not context_chunks:
await message.channel.send("⚠️ No relevant content found in the knowledge base.")
return True
context = "\n\n---\n\n".join(context_chunks)
# Step 4: Generate answer with qwen3-235b
system_prompt = """You are a helpful mental health support assistant with expertise in DBT (Dialectical Behavior Therapy).
Use the provided context to answer the user's question accurately and compassionately.
If the answer is not in the context, say so — do not invent information.
Be concise, practical, and supportive."""
answer = await generate_rag_answer(message.content, context, system_prompt)
await message.channel.send(f"🧠 **Response:**\n{answer}")
return True
async def routeCommand(message): async def routeCommand(message):
@@ -540,10 +634,38 @@ async def routeCommand(message):
) )
def _restore_sessions_from_cache():
"""Try to restore user sessions from cached refresh tokens on startup."""
restored = 0
for discord_id, cached in user_cache.items():
refresh_token = cached.get("refresh_token")
if not refresh_token:
continue
result, status = apiRequest(
"post", "/api/refresh",
data={"refresh_token": refresh_token},
_retried=True,
)
if status == 200 and "token" in result:
token = result["token"]
payload = decodeJwtPayload(token)
user_uuid = payload["sub"]
user_sessions[discord_id] = {
"token": token,
"user_uuid": user_uuid,
"username": cached.get("username", ""),
"refresh_token": refresh_token,
}
restored += 1
if restored:
print(f"Restored {restored} user session(s) from cache")
@client.event @client.event
async def on_ready(): async def on_ready():
print(f"Bot logged in as {client.user}") print(f"Bot logged in as {client.user}")
loadCache() loadCache()
_restore_sessions_from_cache()
backgroundLoop.start() backgroundLoop.start()

View File

@@ -7,6 +7,16 @@ import datetime
import os import os
REFRESH_TOKEN_SECRET = None
def _get_refresh_secret():
global REFRESH_TOKEN_SECRET
if REFRESH_TOKEN_SECRET is None:
REFRESH_TOKEN_SECRET = os.getenv("JWT_SECRET", "") + "_refresh"
return REFRESH_TOKEN_SECRET
def verifyLoginToken(login_token, username=False, userUUID=False): def verifyLoginToken(login_token, username=False, userUUID=False):
if username: if username:
userUUID = users.getUserUUID(username) userUUID = users.getUserUUID(username)
@@ -49,6 +59,44 @@ def getLoginToken(username, password):
return False return False
def createRefreshToken(userUUID):
"""Create a long-lived refresh token (30 days)."""
payload = {
"sub": str(userUUID),
"type": "refresh",
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=30),
}
return jwt.encode(payload, _get_refresh_secret(), algorithm="HS256")
def refreshAccessToken(refresh_token):
"""Validate a refresh token and return a new access token + user_uuid.
Returns (access_token, user_uuid) or (None, None)."""
try:
decoded = jwt.decode(
refresh_token, _get_refresh_secret(), algorithms=["HS256"]
)
if decoded.get("type") != "refresh":
return None, None
user_uuid = decoded.get("sub")
if not user_uuid:
return None, None
# Verify user still exists
user = postgres.select_one("users", {"id": user_uuid})
if not user:
return None, None
# Create new access token
payload = {
"sub": user_uuid,
"name": user.get("first_name", ""),
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1),
}
access_token = jwt.encode(payload, os.getenv("JWT_SECRET"), algorithm="HS256")
return access_token, user_uuid
except (ExpiredSignatureError, InvalidTokenError):
return None, None
def unregisterUser(userUUID, password): def unregisterUser(userUUID, password):
pw_hash = getUserpasswordHash(userUUID) pw_hash = getUserpasswordHash(userUUID)
if not pw_hash: if not pw_hash:

View File

@@ -18,18 +18,27 @@ logger = logging.getLogger(__name__)
def _sendToEnabledChannels(notif_settings, message, user_uuid=None): def _sendToEnabledChannels(notif_settings, message, user_uuid=None):
"""Send message to all enabled channels. Returns True if at least one succeeded.""" """Send message to all enabled channels. Returns True if at least one succeeded."""
sent = False sent = False
logger.info(f"Sending notification to user {user_uuid}: {message[:80]}")
if notif_settings.get("discord_enabled") and notif_settings.get("discord_user_id"): if notif_settings.get("discord_enabled") and notif_settings.get("discord_user_id"):
if discord.send_dm(notif_settings["discord_user_id"], message): if discord.send_dm(notif_settings["discord_user_id"], message):
logger.debug(f"Discord DM sent to {notif_settings['discord_user_id']}")
sent = True sent = True
if notif_settings.get("ntfy_enabled") and notif_settings.get("ntfy_topic"): if notif_settings.get("ntfy_enabled") and notif_settings.get("ntfy_topic"):
if ntfy.send(notif_settings["ntfy_topic"], message): if ntfy.send(notif_settings["ntfy_topic"], message):
logger.debug(f"ntfy sent to topic {notif_settings['ntfy_topic']}")
sent = True sent = True
if notif_settings.get("web_push_enabled") and user_uuid: if notif_settings.get("web_push_enabled") and user_uuid:
if web_push.send_to_user(user_uuid, message): if web_push.send_to_user(user_uuid, message):
logger.debug(f"Web push sent for user {user_uuid}")
sent = True sent = True
else:
logger.warning(f"Web push failed or no subscriptions for user {user_uuid}")
if not sent:
logger.warning(f"No notification channels succeeded for user {user_uuid}")
return sent return sent

View File

@@ -5,9 +5,9 @@ Override poll_callback() with your domain-specific logic.
""" """
import os import os
import time import time as time_module
import logging import logging
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta, time as time_type
import core.postgres as postgres import core.postgres as postgres
import core.notifications as notifications import core.notifications as notifications
@@ -249,6 +249,12 @@ def check_adaptive_medication_reminders():
# Use base time # Use base time
check_time = sched.get("base_time") check_time = sched.get("base_time")
# Normalize TIME objects to "HH:MM" strings for comparison
if isinstance(check_time, time_type):
check_time = check_time.strftime("%H:%M")
elif check_time is not None:
check_time = str(check_time)[:5]
if check_time != current_time: if check_time != current_time:
continue continue
@@ -367,6 +373,11 @@ def check_nagging():
display_time = sched.get("adjusted_time") display_time = sched.get("adjusted_time")
else: else:
display_time = sched.get("base_time") display_time = sched.get("base_time")
# Normalize TIME objects for display
if isinstance(display_time, time_type):
display_time = display_time.strftime("%H:%M")
elif display_time is not None:
display_time = str(display_time)[:5]
# Send nag notification # Send nag notification
user_settings = notifications.getNotificationSettings(user_uuid) user_settings = notifications.getNotificationSettings(user_uuid)
@@ -446,6 +457,39 @@ def _get_distinct_user_uuids():
return uuids return uuids
def _is_med_due_today(med, today):
"""Check if a medication is due on the given date based on its frequency."""
from datetime import date as date_type
freq = med.get("frequency", "daily")
if freq == "as_needed":
return False
if freq == "specific_days":
current_day = today.strftime("%a").lower()
med_days = med.get("days_of_week", [])
if current_day not in med_days:
return False
if freq == "every_n_days":
start = med.get("start_date")
interval = med.get("interval_days")
if start and interval:
start_d = (
start
if isinstance(start, date_type)
else datetime.strptime(str(start), "%Y-%m-%d").date()
)
days_since = (today - start_d).days
if days_since < 0 or days_since % interval != 0:
return False
else:
return False
return True
def _check_per_user_midnight_schedules(): def _check_per_user_midnight_schedules():
"""Create daily adaptive schedules for each user when it's midnight in """Create daily adaptive schedules for each user when it's midnight in
their timezone (within the poll window).""" their timezone (within the poll window)."""
@@ -453,10 +497,13 @@ def _check_per_user_midnight_schedules():
try: try:
now = _user_now_for(user_uuid) now = _user_now_for(user_uuid)
if now.hour == 0 and now.minute < POLL_INTERVAL / 60: if now.hour == 0 and now.minute < POLL_INTERVAL / 60:
today = now.date()
user_meds = postgres.select( user_meds = postgres.select(
"medications", where={"user_uuid": user_uuid, "active": True} "medications", where={"user_uuid": user_uuid, "active": True}
) )
for med in user_meds: for med in user_meds:
if not _is_med_due_today(med, today):
continue
times = med.get("times", []) times = med.get("times", [])
if times: if times:
adaptive_meds.create_daily_schedule( adaptive_meds.create_daily_schedule(
@@ -499,7 +546,7 @@ def daemon_loop():
poll_callback() poll_callback()
except Exception as e: except Exception as e:
logger.error(f"Poll callback error: {e}") logger.error(f"Poll callback error: {e}")
time.sleep(POLL_INTERVAL) time_module.sleep(POLL_INTERVAL)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -9,6 +9,7 @@ export default function LoginPage() {
const [isLogin, setIsLogin] = useState(true); const [isLogin, setIsLogin] = useState(true);
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [trustDevice, setTrustDevice] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { login, register } = useAuth(); const { login, register } = useAuth();
@@ -21,10 +22,10 @@ export default function LoginPage() {
try { try {
if (isLogin) { if (isLogin) {
await login(username, password); await login(username, password, trustDevice);
} else { } else {
await register(username, password); await register(username, password);
await login(username, password); await login(username, password, trustDevice);
} }
router.push('/'); router.push('/');
} catch (err) { } catch (err) {
@@ -80,6 +81,18 @@ export default function LoginPage() {
/> />
</div> </div>
{isLogin && (
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input
type="checkbox"
checked={trustDevice}
onChange={(e) => setTrustDevice(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-indigo-500 focus:ring-indigo-500"
/>
This is a trusted device
</label>
)}
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}

View File

@@ -9,7 +9,7 @@ interface AuthContextType {
token: string | null; token: string | null;
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string, trustDevice?: boolean) => Promise<void>;
register: (username: string, password: string) => Promise<void>; register: (username: string, password: string) => Promise<void>;
logout: () => void; logout: () => void;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
@@ -54,8 +54,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
refreshUser(); refreshUser();
}, [refreshUser]); }, [refreshUser]);
const login = async (username: string, password: string) => { const login = async (username: string, password: string, trustDevice = false) => {
const result = await api.auth.login(username, password); const result = await api.auth.login(username, password, trustDevice);
const storedToken = api.auth.getToken(); const storedToken = api.auth.getToken();
setToken(storedToken); setToken(storedToken);

View File

@@ -11,11 +11,57 @@ function setToken(token: string): void {
function clearToken(): void { function clearToken(): void {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
}
function getRefreshToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('refresh_token');
}
function setRefreshToken(token: string): void {
localStorage.setItem('refresh_token', token);
}
let refreshPromise: Promise<boolean> | null = null;
async function tryRefreshToken(): Promise<boolean> {
// Deduplicate concurrent refresh attempts
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const resp = await fetch(`${API_URL}/api/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (resp.ok) {
const data = await resp.json();
if (data.token) {
setToken(data.token);
return true;
}
}
// Refresh token is invalid/expired - clear everything
clearToken();
return false;
} catch {
return false;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
} }
async function request<T>( async function request<T>(
endpoint: string, endpoint: string,
options: RequestInit = {} options: RequestInit = {},
_retried = false,
): Promise<T> { ): Promise<T> {
const token = getToken(); const token = getToken();
const headers: HeadersInit = { const headers: HeadersInit = {
@@ -31,6 +77,14 @@ async function request<T>(
headers, headers,
}); });
// Auto-refresh on 401
if (response.status === 401 && !_retried) {
const refreshed = await tryRefreshToken();
if (refreshed) {
return request<T>(endpoint, options, true);
}
}
if (!response.ok) { if (!response.ok) {
const body = await response.text(); const body = await response.text();
let errorMsg = 'Request failed'; let errorMsg = 'Request failed';
@@ -49,12 +103,15 @@ async function request<T>(
export const api = { export const api = {
// Auth // Auth
auth: { auth: {
login: async (username: string, password: string) => { login: async (username: string, password: string, trustDevice = false) => {
const result = await request<{ token: string }>('/api/login', { const result = await request<{ token: string; refresh_token?: string }>('/api/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password, trust_device: trustDevice }),
}); });
setToken(result.token); setToken(result.token);
if (result.refresh_token) {
setRefreshToken(result.refresh_token);
}
return result; return result;
}, },