7cec3030c1
Move frontend sources to frontend/, build content-hashed assets at deploy, network-first SW for shell/JS, immutable cache for /assets/, version.json update banner, and Cache-Control middleware. Docs in docs/PWA-STRATEGY.md. Co-authored-by: Cursor <cursoragent@cursor.com>
217 lines
6.9 KiB
Python
217 lines
6.9 KiB
Python
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_roast_of_the_day, 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.static_cache import CacheControlMiddleware
|
|
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_ai_texts() -> None:
|
|
config = load_meds_config()
|
|
db = await get_db()
|
|
try:
|
|
await generate_daily_motivation(db, config.timezone)
|
|
await generate_roast_of_the_day(db, config.timezone)
|
|
except Exception:
|
|
logger.exception("Startup AI text 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_ai_texts())
|
|
yield
|
|
|
|
|
|
app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan)
|
|
app.add_middleware(CacheControlMiddleware)
|
|
|
|
|
|
@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)
|
|
|
|
|
|
# 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")
|