"""Hera Planner - FastAPI Backend""" import json import os import hashlib import time import shutil import threading from datetime import datetime from pathlib import Path from contextlib import contextmanager from fastapi import FastAPI, HTTPException, Request from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse from pydantic import BaseModel from typing import Optional from .database import get_db, init_db, DB_PATH app = FastAPI(title="Hera's Planner") DATA_DIR = os.environ.get("DATA_DIR", "/data") BACKUP_DIR = os.path.join(DATA_DIR, "backups") FRONTEND_DIR = os.environ.get("FRONTEND_DIR", "/app/frontend") DEFAULT_HASH = "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92" # ============================================================ # Helpers # ============================================================ def _ensure_dirs(): Path(DATA_DIR).mkdir(parents=True, exist_ok=True) Path(BACKUP_DIR).mkdir(parents=True, exist_ok=True) def _get_config(key, default=None): conn = get_db() row = conn.execute("SELECT value FROM config WHERE key = ?", (key,)).fetchone() conn.close() return row["value"] if row else default def _set_config(key, value): conn = get_db() conn.execute( "INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", (key, value, value), ) conn.commit() conn.close() def _do_backup(): """Backup the SQLite database.""" if not os.path.exists(DB_PATH): return _ensure_dirs() ts = datetime.now().strftime("%Y%m%d_%H%M%S") dest = os.path.join(BACKUP_DIR, f"planner_{ts}.db") # Use SQLite backup API for consistency import sqlite3 src_conn = sqlite3.connect(DB_PATH) dst_conn = sqlite3.connect(dest) src_conn.backup(dst_conn) dst_conn.close() src_conn.close() # Keep only last 30 backups backups = sorted(Path(BACKUP_DIR).glob("planner_*.db")) for old in backups[:-30]: old.unlink() # WAL checkpoint thread def _wal_checkpoint_loop(): while True: time.sleep(300) try: conn = get_db() conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") conn.close() except Exception: pass # ============================================================ # Startup # ============================================================ @app.on_event("startup") def startup(): _ensure_dirs() init_db() # Start WAL checkpoint thread t = threading.Thread(target=_wal_checkpoint_loop, daemon=True) t.start() # Mount frontend static files dist_assets = os.path.join(FRONTEND_DIR, "assets") if os.path.isdir(dist_assets): app.mount("/assets", StaticFiles(directory=dist_assets), name="assets") # ============================================================ # Auth # ============================================================ class LoginRequest(BaseModel): hash: str class ChangePasswordRequest(BaseModel): oldHash: str newHash: str @app.post("/api/login") def login(req: LoginRequest): stored = _get_config("password_hash", DEFAULT_HASH) if req.hash != stored: raise HTTPException(401, "密码不正确") return {"ok": True} @app.post("/api/change-password") def change_password(req: ChangePasswordRequest): stored = _get_config("password_hash", DEFAULT_HASH) if req.oldHash != stored: raise HTTPException(401, "当前密码不正确") _set_config("password_hash", req.newHash) return {"ok": True} # ============================================================ # Notes (随手记) # ============================================================ @app.get("/api/notes") def list_notes(): conn = get_db() rows = conn.execute("SELECT * FROM notes ORDER BY created_at DESC").fetchall() conn.close() return [dict(r) for r in rows] class NoteIn(BaseModel): id: str text: str tag: str = "灵感" @app.post("/api/notes") def create_note(note: NoteIn): conn = get_db() conn.execute( "INSERT INTO notes (id, text, tag) VALUES (?, ?, ?) " "ON CONFLICT(id) DO UPDATE SET text=?, tag=?, updated_at=datetime('now')", (note.id, note.text, note.tag, note.text, note.tag), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/notes/{note_id}") def delete_note(note_id: str): conn = get_db() conn.execute("DELETE FROM notes WHERE id = ?", (note_id,)) conn.commit() conn.close() return {"ok": True} # ============================================================ # Todos (待办四象限) # ============================================================ @app.get("/api/todos") def list_todos(): conn = get_db() rows = conn.execute("SELECT * FROM todos ORDER BY created_at DESC").fetchall() conn.close() return [dict(r) for r in rows] class TodoIn(BaseModel): id: str text: str quadrant: str = "q1" done: int = 0 @app.post("/api/todos") def upsert_todo(todo: TodoIn): conn = get_db() conn.execute( "INSERT INTO todos (id, text, quadrant, done) VALUES (?, ?, ?, ?) " "ON CONFLICT(id) DO UPDATE SET text=?, quadrant=?, done=?, updated_at=datetime('now')", (todo.id, todo.text, todo.quadrant, todo.done, todo.text, todo.quadrant, todo.done), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/todos/{todo_id}") def delete_todo(todo_id: str): conn = get_db() conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,)) conn.commit() conn.close() return {"ok": True} # ============================================================ # Inbox (收集箱) # ============================================================ @app.get("/api/inbox") def list_inbox(): conn = get_db() rows = conn.execute("SELECT * FROM inbox ORDER BY created_at DESC").fetchall() conn.close() return [dict(r) for r in rows] class InboxIn(BaseModel): id: str text: str @app.post("/api/inbox") def add_inbox(item: InboxIn): conn = get_db() conn.execute( "INSERT OR REPLACE INTO inbox (id, text) VALUES (?, ?)", (item.id, item.text), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/inbox/{item_id}") def delete_inbox(item_id: str): conn = get_db() conn.execute("DELETE FROM inbox WHERE id = ?", (item_id,)) conn.commit() conn.close() return {"ok": True} @app.delete("/api/inbox") def clear_inbox(): conn = get_db() conn.execute("DELETE FROM inbox") conn.commit() conn.close() return {"ok": True} # ============================================================ # Reminders (提醒) # ============================================================ @app.get("/api/reminders") def list_reminders(): conn = get_db() rows = conn.execute("SELECT * FROM reminders ORDER BY time").fetchall() conn.close() return [dict(r) for r in rows] class ReminderIn(BaseModel): id: str text: str time: Optional[str] = None repeat: str = "none" enabled: int = 1 @app.post("/api/reminders") def upsert_reminder(r: ReminderIn): conn = get_db() conn.execute( "INSERT INTO reminders (id, text, time, repeat, enabled) VALUES (?, ?, ?, ?, ?) " "ON CONFLICT(id) DO UPDATE SET text=?, time=?, repeat=?, enabled=?", (r.id, r.text, r.time, r.repeat, r.enabled, r.text, r.time, r.repeat, r.enabled), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/reminders/{reminder_id}") def delete_reminder(reminder_id: str): conn = get_db() conn.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,)) conn.commit() conn.close() return {"ok": True} # ============================================================ # Goals (目标) # ============================================================ @app.get("/api/goals") def list_goals(): conn = get_db() rows = conn.execute("SELECT * FROM goals ORDER BY created_at DESC").fetchall() conn.close() return [dict(r) for r in rows] class GoalIn(BaseModel): id: str name: str month: Optional[str] = None checks: str = "{}" @app.post("/api/goals") def upsert_goal(g: GoalIn): conn = get_db() conn.execute( "INSERT INTO goals (id, name, month, checks) VALUES (?, ?, ?, ?) " "ON CONFLICT(id) DO UPDATE SET name=?, month=?, checks=?", (g.id, g.name, g.month, g.checks, g.name, g.month, g.checks), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/goals/{goal_id}") def delete_goal(goal_id: str): conn = get_db() conn.execute("DELETE FROM goals WHERE id = ?", (goal_id,)) conn.commit() conn.close() return {"ok": True} # ============================================================ # Checklists (清单) # ============================================================ @app.get("/api/checklists") def list_checklists(): conn = get_db() rows = conn.execute("SELECT * FROM checklists ORDER BY created_at DESC").fetchall() conn.close() return [dict(r) for r in rows] class ChecklistIn(BaseModel): id: str title: str items: str = "[]" archived: int = 0 @app.post("/api/checklists") def upsert_checklist(cl: ChecklistIn): conn = get_db() conn.execute( "INSERT INTO checklists (id, title, items, archived) VALUES (?, ?, ?, ?) " "ON CONFLICT(id) DO UPDATE SET title=?, items=?, archived=?", (cl.id, cl.title, cl.items, cl.archived, cl.title, cl.items, cl.archived), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/checklists/{cl_id}") def delete_checklist(cl_id: str): conn = get_db() conn.execute("DELETE FROM checklists WHERE id = ?", (cl_id,)) conn.commit() conn.close() return {"ok": True} # ============================================================ # Health / Music Check-in (打卡) # ============================================================ @app.get("/api/health-items") def list_health_items(type: str = "health"): conn = get_db() rows = conn.execute("SELECT * FROM health_items WHERE type = ?", (type,)).fetchall() conn.close() return [dict(r) for r in rows] class HealthItemIn(BaseModel): id: str name: str type: str = "health" @app.post("/api/health-items") def upsert_health_item(item: HealthItemIn): conn = get_db() conn.execute( "INSERT OR REPLACE INTO health_items (id, name, type) VALUES (?, ?, ?)", (item.id, item.name, item.type), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/health-items/{item_id}") def delete_health_item(item_id: str): conn = get_db() conn.execute("DELETE FROM health_items WHERE id = ?", (item_id,)) conn.commit() conn.close() return {"ok": True} @app.get("/api/health-plans") def get_health_plans(type: str = "health"): conn = get_db() rows = conn.execute("SELECT * FROM health_plans WHERE type = ?", (type,)).fetchall() conn.close() return [dict(r) for r in rows] class HealthPlanIn(BaseModel): month: str type: str = "health" item_ids: str = "[]" @app.post("/api/health-plans") def upsert_health_plan(plan: HealthPlanIn): conn = get_db() conn.execute( "INSERT INTO health_plans (month, type, item_ids) VALUES (?, ?, ?) " "ON CONFLICT(month, type) DO UPDATE SET item_ids=?", (plan.month, plan.type, plan.item_ids, plan.item_ids), ) conn.commit() conn.close() return {"ok": True} @app.get("/api/health-checks") def list_health_checks(type: str = "health", month: Optional[str] = None): conn = get_db() if month: rows = conn.execute( "SELECT * FROM health_checks WHERE type = ? AND date LIKE ?", (type, f"{month}%"), ).fetchall() else: rows = conn.execute("SELECT * FROM health_checks WHERE type = ?", (type,)).fetchall() conn.close() return [dict(r) for r in rows] class HealthCheckIn(BaseModel): date: str type: str = "health" item_id: str checked: int = 1 @app.post("/api/health-checks") def upsert_health_check(check: HealthCheckIn): conn = get_db() if check.checked: conn.execute( "INSERT OR REPLACE INTO health_checks (date, type, item_id, checked) VALUES (?, ?, ?, ?)", (check.date, check.type, check.item_id, check.checked), ) else: conn.execute( "DELETE FROM health_checks WHERE date = ? AND type = ? AND item_id = ?", (check.date, check.type, check.item_id), ) conn.commit() conn.close() return {"ok": True} # ============================================================ # Sleep Records (睡眠) # ============================================================ @app.get("/api/sleep") def list_sleep(): conn = get_db() rows = conn.execute("SELECT * FROM sleep_records ORDER BY date DESC").fetchall() conn.close() return [dict(r) for r in rows] class SleepIn(BaseModel): date: str time: str minutes: Optional[float] = None @app.post("/api/sleep") def upsert_sleep(record: SleepIn): conn = get_db() conn.execute( "INSERT INTO sleep_records (date, time, minutes) VALUES (?, ?, ?) " "ON CONFLICT(date) DO UPDATE SET time=?, minutes=?", (record.date, record.time, record.minutes, record.time, record.minutes), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/sleep/{date}") def delete_sleep(date: str): conn = get_db() conn.execute("DELETE FROM sleep_records WHERE date = ?", (date,)) conn.commit() conn.close() return {"ok": True} # ============================================================ # Gym Records (健身) # ============================================================ @app.get("/api/gym") def list_gym(): conn = get_db() rows = conn.execute("SELECT * FROM gym_records ORDER BY date DESC").fetchall() conn.close() return [dict(r) for r in rows] class GymIn(BaseModel): id: str date: str type: str = "" duration: str = "" note: str = "" @app.post("/api/gym") def upsert_gym(record: GymIn): conn = get_db() conn.execute( "INSERT INTO gym_records (id, date, type, duration, note) VALUES (?, ?, ?, ?, ?) " "ON CONFLICT(id) DO UPDATE SET date=?, type=?, duration=?, note=?", (record.id, record.date, record.type, record.duration, record.note, record.date, record.type, record.duration, record.note), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/gym/{record_id}") def delete_gym(record_id: str): conn = get_db() conn.execute("DELETE FROM gym_records WHERE id = ?", (record_id,)) conn.commit() conn.close() return {"ok": True} # ============================================================ # Period Records (经期) # ============================================================ @app.get("/api/period") def list_period(): conn = get_db() rows = conn.execute("SELECT * FROM period_records ORDER BY start_date DESC").fetchall() conn.close() return [dict(r) for r in rows] class PeriodIn(BaseModel): id: str start_date: str end_date: Optional[str] = None note: str = "" @app.post("/api/period") def upsert_period(record: PeriodIn): conn = get_db() conn.execute( "INSERT INTO period_records (id, start_date, end_date, note) VALUES (?, ?, ?, ?) " "ON CONFLICT(id) DO UPDATE SET start_date=?, end_date=?, note=?", (record.id, record.start_date, record.end_date, record.note, record.start_date, record.end_date, record.note), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/period/{record_id}") def delete_period(record_id: str): conn = get_db() conn.execute("DELETE FROM period_records WHERE id = ?", (record_id,)) conn.commit() conn.close() return {"ok": True} # ============================================================ # Docs (文档) # ============================================================ @app.get("/api/docs") def list_docs(): conn = get_db() docs = conn.execute("SELECT * FROM docs ORDER BY created_at").fetchall() result = [] for d in docs: doc = dict(d) entries = conn.execute( "SELECT * FROM doc_entries WHERE doc_id = ? ORDER BY created_at DESC", (d["id"],), ).fetchall() doc["entries"] = [dict(e) for e in entries] result.append(doc) conn.close() return result class DocIn(BaseModel): id: str name: str icon: str = "📄" keywords: str = "" extract_rule: str = "none" @app.post("/api/docs") def upsert_doc(doc: DocIn): conn = get_db() conn.execute( "INSERT INTO docs (id, name, icon, keywords, extract_rule) VALUES (?, ?, ?, ?, ?) " "ON CONFLICT(id) DO UPDATE SET name=?, icon=?, keywords=?, extract_rule=?", (doc.id, doc.name, doc.icon, doc.keywords, doc.extract_rule, doc.name, doc.icon, doc.keywords, doc.extract_rule), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/docs/{doc_id}") def delete_doc(doc_id: str): conn = get_db() conn.execute("DELETE FROM docs WHERE id = ?", (doc_id,)) conn.commit() conn.close() return {"ok": True} class DocEntryIn(BaseModel): id: str doc_id: str text: str note_id: Optional[str] = None @app.post("/api/doc-entries") def add_doc_entry(entry: DocEntryIn): conn = get_db() conn.execute( "INSERT OR REPLACE INTO doc_entries (id, doc_id, text, note_id) VALUES (?, ?, ?, ?)", (entry.id, entry.doc_id, entry.text, entry.note_id), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/doc-entries/{entry_id}") def delete_doc_entry(entry_id: str): conn = get_db() conn.execute("DELETE FROM doc_entries WHERE id = ?", (entry_id,)) conn.commit() conn.close() return {"ok": True} # ============================================================ # Schedule (日程) # ============================================================ @app.get("/api/schedule-modules") def list_schedule_modules(): conn = get_db() rows = conn.execute("SELECT * FROM schedule_modules ORDER BY sort_order").fetchall() conn.close() return [dict(r) for r in rows] class ScheduleModuleIn(BaseModel): id: str name: str emoji: str = "📌" color: str = "#667eea" sort_order: int = 0 @app.post("/api/schedule-modules") def upsert_schedule_module(m: ScheduleModuleIn): conn = get_db() conn.execute( "INSERT INTO schedule_modules (id, name, emoji, color, sort_order) VALUES (?, ?, ?, ?, ?) " "ON CONFLICT(id) DO UPDATE SET name=?, emoji=?, color=?, sort_order=?", (m.id, m.name, m.emoji, m.color, m.sort_order, m.name, m.emoji, m.color, m.sort_order), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/schedule-modules/{module_id}") def delete_schedule_module(module_id: str): conn = get_db() conn.execute("DELETE FROM schedule_modules WHERE id = ?", (module_id,)) conn.execute("DELETE FROM schedule_slots WHERE module_id = ?", (module_id,)) conn.commit() conn.close() return {"ok": True} @app.get("/api/schedule-slots") def list_schedule_slots(date: Optional[str] = None): conn = get_db() if date: rows = conn.execute("SELECT * FROM schedule_slots WHERE date = ?", (date,)).fetchall() else: rows = conn.execute("SELECT * FROM schedule_slots").fetchall() conn.close() return [dict(r) for r in rows] class ScheduleSlotIn(BaseModel): date: str time_slot: str module_id: str @app.post("/api/schedule-slots") def add_schedule_slot(slot: ScheduleSlotIn): conn = get_db() conn.execute( "INSERT OR REPLACE INTO schedule_slots (date, time_slot, module_id) VALUES (?, ?, ?)", (slot.date, slot.time_slot, slot.module_id), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/schedule-slots") def delete_schedule_slots(date: str, time_slot: Optional[str] = None, module_id: Optional[str] = None): conn = get_db() if time_slot and module_id: conn.execute( "DELETE FROM schedule_slots WHERE date = ? AND time_slot = ? AND module_id = ?", (date, time_slot, module_id), ) elif time_slot: conn.execute("DELETE FROM schedule_slots WHERE date = ? AND time_slot = ?", (date, time_slot)) else: conn.execute("DELETE FROM schedule_slots WHERE date = ?", (date,)) conn.commit() conn.close() return {"ok": True} # ============================================================ # Weekly Template (每周模板) # ============================================================ @app.get("/api/weekly-template") def get_weekly_template(): conn = get_db() rows = conn.execute("SELECT * FROM weekly_template ORDER BY day").fetchall() conn.close() return [dict(r) for r in rows] class WeeklyTemplateIn(BaseModel): day: int data: str = "[]" @app.post("/api/weekly-template") def upsert_weekly_template(t: WeeklyTemplateIn): conn = get_db() conn.execute( "INSERT INTO weekly_template (day, data) VALUES (?, ?) " "ON CONFLICT(day) DO UPDATE SET data=?", (t.day, t.data, t.data), ) conn.commit() conn.close() return {"ok": True} # ============================================================ # Reviews (周回顾) # ============================================================ @app.get("/api/reviews") def list_reviews(): conn = get_db() rows = conn.execute("SELECT * FROM reviews ORDER BY week DESC").fetchall() conn.close() return [dict(r) for r in rows] class ReviewIn(BaseModel): week: str data: str = "{}" @app.post("/api/reviews") def upsert_review(r: ReviewIn): conn = get_db() conn.execute( "INSERT INTO reviews (week, data) VALUES (?, ?) " "ON CONFLICT(week) DO UPDATE SET data=?", (r.week, r.data, r.data), ) conn.commit() conn.close() return {"ok": True} # ============================================================ # Bugs # ============================================================ @app.get("/api/bugs") def list_bugs(): conn = get_db() rows = conn.execute("SELECT * FROM bugs ORDER BY created_at DESC").fetchall() conn.close() return [dict(r) for r in rows] class BugIn(BaseModel): id: str text: str status: str = "open" @app.post("/api/bugs") def upsert_bug(bug: BugIn): conn = get_db() conn.execute( "INSERT INTO bugs (id, text, status) VALUES (?, ?, ?) " "ON CONFLICT(id) DO UPDATE SET text=?, status=?", (bug.id, bug.text, bug.status, bug.text, bug.status), ) conn.commit() conn.close() return {"ok": True} @app.delete("/api/bugs/{bug_id}") def delete_bug(bug_id: str): conn = get_db() conn.execute("DELETE FROM bugs WHERE id = ?", (bug_id,)) conn.commit() conn.close() return {"ok": True} # ============================================================ # Sleep Buddy (独立的社交睡眠打卡) # ============================================================ class BuddyRegister(BaseModel): username: str hash: str @app.post("/api/buddy-register") def buddy_register(req: BuddyRegister): username = req.username.strip() if not username or not req.hash: raise HTTPException(400, "请输入用户名和密码") conn = get_db() existing = conn.execute("SELECT username FROM buddy_users WHERE username = ?", (username,)).fetchone() if existing: conn.close() raise HTTPException(400, "用户名已存在") conn.execute( "INSERT INTO buddy_users (username, password_hash) VALUES (?, ?)", (username, req.hash), ) conn.commit() conn.close() return {"ok": True} class BuddyLogin(BaseModel): username: str hash: str @app.post("/api/buddy-login") def buddy_login(req: BuddyLogin): username = req.username.strip() conn = get_db() user = conn.execute( "SELECT * FROM buddy_users WHERE username = ? AND password_hash = ?", (username, req.hash), ).fetchone() conn.close() if not user: raise HTTPException(401, "用户名或密码不正确") return {"ok": True, "username": username} class BuddyDeleteUser(BaseModel): adminHash: str username: str @app.post("/api/buddy-delete-user") def buddy_delete_user(req: BuddyDeleteUser): stored = _get_config("password_hash", DEFAULT_HASH) if req.adminHash != stored: raise HTTPException(401, "需要管理员密码") conn = get_db() conn.execute("DELETE FROM buddy_users WHERE username = ?", (req.username,)) conn.execute("DELETE FROM buddy_records WHERE username = ?", (req.username,)) conn.commit() conn.close() return {"ok": True} @app.get("/api/sleep-buddy") def get_buddy_data(): conn = get_db() users_rows = conn.execute("SELECT * FROM buddy_users").fetchall() users = {} for u in users_rows: records = conn.execute( "SELECT date, time FROM buddy_records WHERE username = ? ORDER BY date DESC LIMIT 60", (u["username"],), ).fetchall() users[u["username"]] = [dict(r) for r in records] targets = {} for u in users_rows: targets[u["username"]] = u["target_time"] notifs = conn.execute( "SELECT * FROM buddy_notifications ORDER BY created_at DESC LIMIT 20" ).fetchall() conn.close() return { "users": users, "targets": targets, "notifications": [dict(n) for n in notifs], } class BuddyAction(BaseModel): user: str action: str record: Optional[dict] = None date: Optional[str] = None target: Optional[str] = None @app.post("/api/sleep-buddy") def buddy_action(req: BuddyAction): conn = get_db() if req.action == "record": record = req.record or {} conn.execute( "INSERT INTO buddy_records (username, date, time) VALUES (?, ?, ?) " "ON CONFLICT(username, date) DO UPDATE SET time=?", (req.user, record.get("date", ""), record.get("time", ""), record.get("time", "")), ) conn.commit() conn.close() return {"ok": True} elif req.action == "delete-record": conn.execute( "DELETE FROM buddy_records WHERE username = ? AND date = ?", (req.user, req.date), ) conn.commit() conn.close() return {"ok": True} elif req.action == "sleep-now": now = datetime.now() conn.execute( "INSERT INTO buddy_notifications (from_user, message, time, date, created_at) " "VALUES (?, ?, ?, ?, ?)", ( req.user, f"{req.user} 去睡觉啦,你也早点休息!", now.strftime("%H:%M"), now.strftime("%Y-%m-%d"), time.time(), ), ) # Keep only last 20 notifications conn.execute(""" DELETE FROM buddy_notifications WHERE id NOT IN ( SELECT id FROM buddy_notifications ORDER BY created_at DESC LIMIT 20 ) """) conn.commit() conn.close() return {"ok": True} elif req.action == "get-notifications": cutoff = time.time() - 86400 rows = conn.execute( "SELECT * FROM buddy_notifications WHERE from_user != ? AND created_at > ? ORDER BY created_at DESC", (req.user, cutoff), ).fetchall() conn.close() return {"notifications": [dict(r) for r in rows]} elif req.action == "set-target": conn.execute( "UPDATE buddy_users SET target_time = ? WHERE username = ?", (req.target or "22:00", req.user), ) conn.commit() conn.close() return {"ok": True} conn.close() return {"ok": True} # ============================================================ # Backups # ============================================================ @app.get("/api/backups") def list_backups(): _ensure_dirs() backups = sorted(Path(BACKUP_DIR).glob("planner_*.db"), reverse=True) items = [] for b in backups[:20]: items.append({ "name": b.name, "size": b.stat().st_size, "time": b.stem.split("_", 1)[1], }) return items @app.post("/api/backup") def trigger_backup(): _do_backup() return {"ok": True} # ============================================================ # SPA Fallback # ============================================================ @app.get("/{path:path}") async def spa_fallback(path: str): # Try static file first file_path = os.path.join(FRONTEND_DIR, path) if path and os.path.isfile(file_path): return FileResponse(file_path) # SPA fallback index = os.path.join(FRONTEND_DIR, "index.html") if os.path.isfile(index): return FileResponse(index, headers={ "Cache-Control": "no-cache, no-store, must-revalidate", }) return JSONResponse({"error": "Not found"}, status_code=404)