from __future__ import annotations import asyncio import logging import pathlib from contextlib import asynccontextmanager from datetime import datetime from typing import Any from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from server.ai import generate_daily_motivation, generate_oracle, get_oracle, get_roast_of_the_day from server.auth import create_token, require_auth, verify_pin from server.config_loader import load_meds_config, slot_to_dict, today_str from server.db import ensure_schema, get_db, new_id, utc_now_iso from server.messages import pick from server.push import save_subscription, send_slot_reminder from server.scheduler import schedule_snooze, start_scheduler from server.settings import settings from server.slots import build_today from server.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS NO_STORE = {"Cache-Control": "no-store"} def _configure_logging() -> None: fmt = logging.Formatter("%(levelname)s: %(message)s") server_log = logging.getLogger("server") if not server_log.handlers: handler = logging.StreamHandler() handler.setFormatter(fmt) server_log.addHandler(handler) server_log.setLevel(logging.INFO) server_log.propagate = False _configure_logging() logger = logging.getLogger(__name__) async def _ensure_today_motivation() -> None: config = load_meds_config() db = await get_db() try: await generate_daily_motivation(db, config.timezone) except Exception: logger.exception("Startup motivation generation failed") finally: await db.close() async def _ensure_today_oracle() -> None: config = load_meds_config() db = await get_db() try: await generate_oracle(db, config.timezone) except Exception: logger.exception("Startup oracle generation failed") finally: await db.close() @asynccontextmanager async def lifespan(app: FastAPI): db = await get_db() await ensure_schema(db) await db.close() start_scheduler() asyncio.create_task(_ensure_today_motivation()) asyncio.create_task(_ensure_today_oracle()) yield app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan) @app.post("/api/auth/pin") async def auth_pin(payload: dict[str, Any]) -> JSONResponse: pin = str(payload.get("pin", "")) if not verify_pin(pin): raise HTTPException(status_code=401, detail="Falscher PIN") return JSONResponse({"token": create_token()}, headers=NO_STORE) @app.get("/api/vapid-public-key") async def vapid_public_key() -> JSONResponse: return JSONResponse({"key": settings.vapid_public_key}, headers=NO_STORE) @app.get("/api/config") async def get_config(_: dict = Depends(require_auth)) -> JSONResponse: config = load_meds_config() return JSONResponse( { "timezone": str(config.timezone), "slots": [slot_to_dict(s) for s in config.slots], "version": settings.app_version, }, headers=NO_STORE, ) @app.get("/api/today") async def get_today(_: dict = Depends(require_auth)) -> JSONResponse: db = await get_db() try: data = await build_today(db) finally: await db.close() return JSONResponse(data, headers=NO_STORE) @app.post("/api/log") async def post_log(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse: slot_id = str(payload.get("slot_id", "")) status = str(payload.get("status", "taken")) source = str(payload.get("source", "app")) if status not in ("taken", "missed", "snoozed"): raise HTTPException(status_code=400, detail="Invalid status") config = load_meds_config() if slot_id not in {s.id for s in config.slots}: raise HTTPException(status_code=400, detail="Unknown slot") day = payload.get("day") or today_str(config.timezone) logged_at = payload.get("logged_at") or utc_now_iso() db = await get_db() try: await db.execute("DELETE FROM intake_log WHERE slot_id = ? AND day = ?", (slot_id, day)) await db.execute( "INSERT INTO intake_log (id, slot_id, day, status, logged_at, source) VALUES (?, ?, ?, ?, ?, ?)", (new_id(), slot_id, day, status, logged_at, source), ) if status == "taken": await db.execute("DELETE FROM snooze WHERE slot_id = ? AND day = ?", (slot_id, day)) await db.commit() stats = await compute_stats(db) new_milestones = await check_milestones(db, stats["streak"]) finally: await db.close() message = pick("success") if status == "taken" else pick("missed") return JSONResponse( { "ok": True, "message": message, "stats": stats, "new_milestones": [ {"id": m, "title": MILESTONE_DEFS.get(m, m)} for m in new_milestones ], }, headers=NO_STORE, ) @app.get("/api/history") async def get_history(days: int = 90, _: dict = Depends(require_auth)) -> JSONResponse: db = await get_db() try: data = await build_history(db, days=min(days, 365)) finally: await db.close() return JSONResponse(data, headers=NO_STORE) @app.get("/api/stats") async def get_stats(_: dict = Depends(require_auth)) -> JSONResponse: db = await get_db() try: stats = await compute_stats(db) finally: await db.close() return JSONResponse(stats, headers=NO_STORE) @app.post("/api/push/subscribe") async def push_subscribe(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse: sub = payload.get("subscription") if not sub or not sub.get("endpoint"): raise HTTPException(status_code=400, detail="Invalid subscription") db = await get_db() try: await save_subscription(db, sub) finally: await db.close() return JSONResponse({"ok": True}, headers=NO_STORE) @app.post("/api/snooze") async def post_snooze(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse: slot_id = str(payload.get("slot_id", "")) minutes = int(payload.get("minutes", 15)) if minutes not in (15, 30): raise HTTPException(status_code=400, detail="minutes must be 15 or 30") config = load_meds_config() if slot_id not in {s.id for s in config.slots}: raise HTTPException(status_code=400, detail="Unknown slot") until = await schedule_snooze(slot_id, minutes) return JSONResponse( {"ok": True, "snooze_until": until, "message": pick("snooze")}, headers=NO_STORE, ) @app.get("/api/roast") async def get_roast(_: dict = Depends(require_auth)) -> JSONResponse: config = load_meds_config() db = await get_db() try: data = await get_roast_of_the_day(db, config.timezone) finally: await db.close() return JSONResponse(data, headers=NO_STORE) @app.get("/api/oracle") async def get_oracle_route(_: dict = Depends(require_auth)) -> JSONResponse: config = load_meds_config() db = await get_db() try: data = await get_oracle(db, config.timezone) finally: await db.close() return JSONResponse(data, headers=NO_STORE) # Static files — must be after API routes PUBLIC = pathlib.Path(settings.public_dir) if PUBLIC.exists(): app.mount("/", StaticFiles(directory=str(PUBLIC), html=True), name="static")