Files
qtalker---/backend/app/main.py
Indiana 3b33b5d6f6 feat: presence beyond the tab — PWA manifest and the Ghost Log recap
Usability wave Workstream C (#5, #7):
- manifest.webmanifest with existing 192/512/180 icons, linked in
  index.html with theme-color aligned to #07070d; no service worker.
- GET /api/seances/recent: last 12 of the seeker's own séances with
  entity, per-kind event counts (one GROUP BY) and up to 3 spirit
  echoes (one windowed query) — no per-session N+1.
- /log Ghost Log page: cards with GhostGlyph, relative in-fiction
  timestamps, counts and echo lines; LOG link in the séance topbar;
  en/es i18n parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 17:19:55 +00:00

145 lines
5.5 KiB
Python

import asyncio
import contextlib
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
import app.models # noqa: F401 — registers models on Base.metadata before create_all
from app.config import settings
from app.db import Base, async_session_maker, engine
from app.routes.auth import router as auth_router
from app.routes.codex import router as codex_router
from app.routes.device import router as device_router
from app.routes.inventory import router as inventory_router
from app.routes.seances import router as seances_router
from app.routes.shop import router as shop_router
from app.session_cleanup import delete_expired_sessions
from app.ws import AUDIO_DIR
from app.ws import router as ws_router
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
SESSION_CLEANUP_INTERVAL_SECONDS = 30 * 60
async def _session_cleanup_loop() -> None:
"""Periodically sweeps expired auth_sessions rows so the table doesn't
grow forever — get_current_user already rejects expired sessions on
read, this just deletes the rows themselves."""
try:
while True:
await asyncio.sleep(SESSION_CLEANUP_INTERVAL_SECONDS)
try:
async with async_session_maker() as db:
await delete_expired_sessions(db)
except Exception:
# A transient DB hiccup shouldn't kill the sweep loop —
# just try again next interval.
pass
except asyncio.CancelledError:
pass
@asynccontextmanager
async def lifespan(app: FastAPI):
AUDIO_DIR.mkdir(parents=True, exist_ok=True)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# No Alembic in this repo — `create_all` never alters existing
# tables, so columns added to live models need a manual, idempotent
# migration here. Safe to run on every startup.
await conn.execute(text(
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS traits JSONB NOT NULL DEFAULT '{}'::jsonb"
))
# Workstream C (character-depth-ghost-log spec) — missing from C's
# own commit, added by the integrator after Workstream B's report
# flagged that User.essence had a live model column and application
# code (auth/me, inventory purchases, summon trickle) but no
# migration, which would have broken on the real production DB.
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS essence INTEGER NOT NULL DEFAULT 0"
))
# Workstream B (character-depth-ghost-log spec).
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS favor DOUBLE PRECISION NOT NULL DEFAULT 0.0"
))
await conn.execute(text(
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false"
))
# Defense-in-depth: purchase_unlock() already enforces one row per
# (user, unlock_key) via a row-locked check-then-insert, so this
# constraint should never actually find a conflict on a live DB.
# `ADD CONSTRAINT` has no IF NOT EXISTS form, so the guard is a
# catalog check instead — safe to run on every startup.
await conn.execute(text(
"DO $$ BEGIN "
"IF NOT EXISTS ("
" SELECT 1 FROM pg_constraint WHERE conname = 'uq_unlocks_user_key'"
") THEN "
" ALTER TABLE unlocks ADD CONSTRAINT uq_unlocks_user_key UNIQUE (user_id, unlock_key); "
"END IF; "
"END $$;"
))
cleanup_task = asyncio.create_task(_session_cleanup_loop())
try:
yield
finally:
cleanup_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await cleanup_task
app = FastAPI(title="Quantumancy", lifespan=lifespan)
app.include_router(auth_router)
app.include_router(codex_router)
app.include_router(device_router)
app.include_router(inventory_router)
app.include_router(seances_router)
app.include_router(shop_router)
app.include_router(ws_router)
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
app.mount(
"/assets",
StaticFiles(directory=FRONTEND_DIST / "assets", check_dir=False),
name="frontend-assets",
)
app.mount(
"/audio",
StaticFiles(directory=AUDIO_DIR, check_dir=False),
name="spirit-audio",
)
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
index_file = FRONTEND_DIST / "index.html"
if not index_file.exists():
raise HTTPException(
status_code=503,
detail="Frontend not built. Run `npm run build` in frontend/ and restart.",
)
# Vite emits root-level static files (favicon.ico, favicon.svg,
# apple-touch-icon.png, og-image.png, …) straight into dist/ rather than
# dist/assets/ — the only mounted static dir. Without this, requests for
# them fall through to the SPA fallback below and get index.html back
# instead of the actual file (browsers silently ignore it; social-media
# link-preview crawlers fetching og:image get an HTML page).
if full_path:
dist_root = FRONTEND_DIST.resolve()
candidate = (dist_root / full_path).resolve()
if candidate.is_file() and dist_root in candidate.parents:
return FileResponse(candidate)
return FileResponse(index_file)