"""Saved-chat API — save and view chat history (phase 50, task 02). Admin-only CRUD under ``/api/chats`` (the phase-16 :func:`app.core.auth.require_admin` gate, applied router-wide exactly like :mod:`app.api.steering`): conversations the owner explicitly **Saves** are stored in Postgres (``saved_chats``, migration 0008). A10 extension (owner permission 2026-08-29, recorded per AGENTS.md rule 3 — a recorded revision, not a silent deviation): ``/api/chat`` itself stays stateless; nothing is stored about a conversation that was not saved, and phase 14's browser-local persistence is untouched (saving is an additional, explicit action). The stored ``messages`` payload is the exact ``bor.chat.v1`` localStorage record shape (phase 14 — raw text, never HTML), so a saved chat restores pixel-identical through the existing ``renderStoredMessage`` path. Routes: ``GET`` (list, latest activity first — no payloads, but the phase-51 ``share_url`` and the phase-53 ``stale`` flag so the History's Share and Stale columns render without a second fetch), ``POST`` (create — auto-title from the first question when no ``title`` is supplied; (phase 51, task 02) ``share: true`` sets the token in the SAME commit — the save-then-share contract; (phase 53, task 03) stamps the row's ``sources_version`` on the PENDING row so it ships in the same INSERT), ``GET /{chat_id}`` (full payload), ``PUT /{chat_id}`` (re-Save upsert — full ``messages`` replacement, ``title`` replaced only when supplied; re-stamps ``sources_version`` to the current generation — a Re-Save is the owner affirming this content against the current KB), ``DELETE /{chat_id}``, and (phase 51, task 01) ``POST /{chat_id}/share`` / ``POST /{chat_id}/unshare`` on this admin-gated router. Staleness (phase 53, task 03): every saved row carries the ``sources_meta`` generation it was saved against (``sources_version``, migration 0010). ``GET``/list/detail expose ``stale`` — true iff the row's stamp is behind the current generation, computed server-side from ONE ``current_sources_version`` read per request (the client never does staleness math). Share/unshare stay version-immune (raw SQL on ``share_token`` only — the version, like ``updated_at``, is untouched); ``SharedChatOut`` (the public snapshot) carries no staleness surface at all — it is frozen by design (phase 51). Sharing (phase 51, owner-locked 2026-08-29): a saved chat's ``share_token`` (a 128-bit ``uuid4``, migration 0009) makes it publicly readable at ``/shared/`` — two more routers in this file, registered in ``app.main`` with **no** admin dependency: * ``public_router`` — ``GET /shared/{token}`` (mounted under ``/api`` → ``GET /api/shared/``): the anonymous read, a minimal ``SharedChatOut`` snapshot (no id/timestamps/token); wrong or revoked tokens 404 with one message (no enumeration). * ``shared_page_router`` — ``GET /shared/{token}`` (mounted with **no** prefix, before the static catch-all): serves ``frontend/shared.html`` (the page lands in phase 51, task 03). A stale deploy without the file 404s with the SAME JSON as the API — never a 500. """ from __future__ import annotations import uuid from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Response from fastapi.responses import FileResponse, JSONResponse from sqlalchemy import select, text from sqlalchemy.orm import Session from app.config import get_settings from app.core.auth import require_admin from app.db import get_db from app.models import SavedChat from app.rag.sources_meta import current_sources_version from app.schemas import ( ChatMessage, SavedChatCreate, SavedChatList, SavedChatOut, SavedChatRow, SavedChatUpdate, SharedChatOut, ShareOut, UnshareOut, ) router = APIRouter( prefix="/chats", tags=["chats"], dependencies=[Depends(require_admin)], # phase 16: save/history is admin-only ) #: Auto-title cap (owner-locked convention, phase 50): the first user #: message's text, whitespace-collapsed, truncated to 120 chars. _AUTO_TITLE_MAX = 120 def _auto_title(messages: list[ChatMessage]) -> str: """The auto-title (owner-locked, phase 50): the **first user message**'s text, whitespace-collapsed, truncated to 120 chars. Returns "" when the conversation has no user message (defensive — the UI cannot produce one; the route then falls back to ``"Chat "``). """ first_user = next((m.text for m in messages if m.who == "user"), None) if first_user is None: return "" return " ".join(first_user.split())[:_AUTO_TITLE_MAX] def _share_url(row: SavedChat) -> str | None: """The row's share link path (phase 51) — ``None`` when unshared. ``None`` is ABSENT from the JSON (the schemas' omission rule), so an unshared chat exposes no share surface at all. """ return f"/shared/{row.share_token}" if row.share_token else None def _to_out(row: SavedChat, current_version: int) -> SavedChatOut: """The full-payload response shape (create/get/put). ``current_version`` is the caller's ONE per-request ``current_sources_version`` read — the helpers stay pure (no session argument, no second query); ``stale`` is true iff the row's stamp is behind that generation. """ return SavedChatOut( id=row.id, title=row.title, created_at=row.created_at, updated_at=row.updated_at, message_count=len(row.messages), messages=[ChatMessage.model_validate(m) for m in row.messages], share_url=_share_url(row), stale=row.sources_version < current_version, ) def _to_row(row: SavedChat, current_version: int) -> SavedChatRow: """The list-page row shape (no payloads in the list; ``share_url`` and ``stale`` are not payloads — they are the History Share and Stale columns' data). ``current_version`` is the caller's ONE per-request read (see :func:`_to_out`).""" return SavedChatRow( id=row.id, title=row.title, updated_at=row.updated_at, message_count=len(row.messages), share_url=_share_url(row), stale=row.sources_version < current_version, ) @router.get("", response_model=SavedChatList) def list_chats( db: Session = Depends(get_db), # noqa: B008 ) -> SavedChatList: """All saved chats, latest activity first (``updated_at desc, id desc``) — the History page's table order. Each row carries the phase-53 ``stale`` flag: the current generation is read ONCE per request (one PK read of the seeded row) and compared against every row's stamp in the serializer helpers — no per-row queries.""" current_version = current_sources_version(db) rows = db.scalars( select(SavedChat).order_by(SavedChat.updated_at.desc(), SavedChat.id.desc()) ).all() return SavedChatList(chats=[_to_row(row, current_version) for row in rows]) @router.post("", response_model=SavedChatOut, status_code=201) def create_chat( payload: SavedChatCreate, db: Session = Depends(get_db), # noqa: B008 ) -> SavedChatOut: """Store one explicitly saved conversation (201). Auto-title when ``title`` is absent/blank: the first user message's text, whitespace-collapsed, truncated to 120 chars (owner-locked convention); a conversation with no user message (defensive) falls back to ``"Chat "``. ``share: true`` (phase 51, task 02 — the save-then-share contract): the fresh row carries ``share_token = uuid.uuid4()`` in the SAME INSERT/commit — one request saves AND shares, and the 201 body carries ``share_url`` (the chat page copies it in one action). ``sources_version`` (phase 53, task 03): stamped on the PENDING row with the current generation — it ships in the SAME INSERT (the ``share_token`` precedent), so a save can never be committed unstamped, and the 201 body's ``stale`` flag (freshly stamped → always false) is honest by construction. """ title = (payload.title or "").strip() or _auto_title(payload.messages) current_version = current_sources_version(db) # once per request row = SavedChat( title=title, # Plain model_dump (no exclude_none): the stored JSONB keeps # every bor.chat.v1 key, explicit null included — the phase-37 # tool records carry `argument: null` in localStorage, so this # is what makes a real payload round-trip byte-identical (the # restore path is null-safe for every optional key). messages=[m.model_dump() for m in payload.messages], # Phase 53: the KB generation this save is made against, set # before db.add so it rides the same INSERT (see above). sources_version=current_version, ) if payload.share: # Phase 51: set on the PENDING row, so the token ships in the # same INSERT (one commit — save and share are one action). row.share_token = uuid.uuid4() db.add(row) db.flush() # python-side uuid default lands the id before the fallback if not row.title: row.title = f"Chat {row.id.hex[:8]}" db.commit() db.refresh(row) return _to_out(row, current_version) @router.get("/{chat_id}", response_model=SavedChatOut) def get_chat( chat_id: uuid.UUID, db: Session = Depends(get_db), # noqa: B008 ) -> SavedChatOut: """One saved chat, full payload (the ``?chat=`` load); 404 when the id is unknown. The ``stale`` flag (phase 53) tells the chat page whether to reveal its stale banner (task 05) before the messages render.""" row = db.get(SavedChat, chat_id) if row is None: raise HTTPException(status_code=404, detail="unknown chat") return _to_out(row, current_sources_version(db)) @router.put("/{chat_id}", response_model=SavedChatOut) def update_chat( chat_id: uuid.UUID, payload: SavedChatUpdate, db: Session = Depends(get_db), # noqa: B008 ) -> SavedChatOut: """Re-Save upsert: full ``messages`` replacement on the same row. ``title`` is replaced only when supplied (an absent/blank ``title`` keeps the current one); 404 when the id is unknown. ``updated_at`` bumps via the model's ``onupdate=func.now()`` — the attribute assignment above is the ORM change that triggers it, so the History page's "latest activity first" order follows re-Saves. ``sources_version`` is re-stamped to the CURRENT generation unconditionally (phase 53, task 03 — ASSUMPTION: a Re-Save ALWAYS re-stamps, not "only when stale"): re-Saving is the owner affirming this content against the current KB, which makes the response's ``stale`` flag false and doubles as the manual escape hatch for a false-positive stale row (one fewer code path than a conditional re-stamp). """ row = db.get(SavedChat, chat_id) if row is None: raise HTTPException(status_code=404, detail="unknown chat") current_version = current_sources_version(db) # once per request row.messages = [m.model_dump() for m in payload.messages] title = (payload.title or "").strip() if title: row.title = title # Phase 53: the affirmation stamp — see the docstring above. row.sources_version = current_version db.commit() db.refresh(row) return _to_out(row, current_version) @router.delete("/{chat_id}", status_code=204) def delete_chat( chat_id: uuid.UUID, db: Session = Depends(get_db), # noqa: B008 ) -> Response: """Remove a saved chat; 404 when the id is unknown.""" row = db.get(SavedChat, chat_id) if row is None: raise HTTPException(status_code=404, detail="unknown chat") db.delete(row) db.commit() return Response(status_code=204) @router.post("/{chat_id}/share", response_model=ShareOut) def share_chat( chat_id: uuid.UUID, db: Session = Depends(get_db), # noqa: B008 ) -> ShareOut: """Turn a saved chat into a public link (phase 51, task 01). Returns ``{"chat_id", "share_url"}`` with ``share_url = "/shared/"`` (200, idempotent — an existing token is returned unchanged; a new token is a 128-bit ``uuid4``). ``updated_at`` is **not** bumped: sharing is not a content edit, so the token is written with a raw SQL ``UPDATE`` that touches ONLY ``share_token`` — the column's ``onupdate=func.now()`` default is registered on the Table (via ``mapped_column``), so even a Core ``update(SavedChat)`` DML statement would pick it up; the ORM object is never mutated either. 404 when the id is unknown. """ row = db.get(SavedChat, chat_id) if row is None: raise HTTPException(status_code=404, detail="unknown chat") token = row.share_token if token is None: token = uuid.uuid4() # Raw SQL on purpose: sets ONLY share_token, so the column's # onupdate default for ``updated_at`` never fires (the History # page's "latest activity" order must follow content edits only). db.execute( text("UPDATE saved_chats SET share_token = :tok WHERE id = :id"), {"tok": token, "id": chat_id}, ) db.commit() return ShareOut(chat_id=row.id, share_url=f"/shared/{token}") @router.post("/{chat_id}/unshare", response_model=UnshareOut) def unshare_chat( chat_id: uuid.UUID, db: Session = Depends(get_db), # noqa: B008 ) -> UnshareOut: """Revoke a shared chat (phase 51, task 01): ``share_token`` → NULL. Idempotent — an unshared chat unshares cleanly (200, no write). ``updated_at`` is not bumped (raw SQL ``UPDATE`` touching only ``share_token``, same reasoning as :func:`share_chat`); 404 when the id is unknown. """ row = db.get(SavedChat, chat_id) if row is None: raise HTTPException(status_code=404, detail="unknown chat") if row.share_token is not None: db.execute( text("UPDATE saved_chats SET share_token = NULL WHERE id = :id"), {"id": chat_id}, ) db.commit() return UnshareOut(chat_id=row.id, shared=False) #: Public read surface (phase 51, task 01) — **no** admin dependency: #: a guest with the link reads the shared chat anonymously. Mounted #: under ``/api`` in ``app.main`` → ``GET /api/shared/``. public_router = APIRouter(tags=["chats"]) def _to_shared_out(row: SavedChat) -> SharedChatOut: """The PUBLIC read shape: title + messages only — no id, timestamps, or token (a content snapshot, not a handle).""" return SharedChatOut( title=row.title, messages=[ChatMessage.model_validate(m) for m in row.messages], ) @public_router.get("/shared/{token}", response_model=SharedChatOut) def read_shared_chat( token: uuid.UUID, db: Session = Depends(get_db), # noqa: B008 ) -> SharedChatOut: """Anonymous read of a shared chat by token (phase 51, task 01). No admin dependency — the token IS the credential. A wrong or a revoked (unshared) token 404s with ONE message: ``unknown or revoked share link`` — deliberately no enumeration between the two cases. """ row = db.execute( select(SavedChat).where(SavedChat.share_token == token).limit(1) ).scalars().first() if row is None: raise HTTPException(status_code=404, detail="unknown or revoked share link") return _to_shared_out(row) #: The share PAGE route (phase 51, task 01) — mounted with **no** #: prefix, BEFORE the static catch-all in ``app.main`` (the #: API-routes-first convention): ``/shared/`` is not a static #: file, so without this route the ``StaticFiles`` mount would 404 it. shared_page_router = APIRouter(tags=["chats"]) #: The page's filename inside the static dir (the file lands in phase #: 51, task 03; until then the guard below keeps a stale deploy from #: 500'ing). _SHARED_PAGE_NAME = "shared.html" @shared_page_router.get("/shared/{token}", response_model=None) def shared_page(token: uuid.UUID) -> FileResponse | JSONResponse: """Serve the anonymous shared-chat page for ``/shared/``. The page (``frontend/shared.html``, task 03) fetches ``GET /api/shared/`` itself and renders the conversation read-only — including the "invalid or revoked" state for a wrong or revoked token — so this route serves the page for any well- formed token and never 404s on the token's validity. The guard: when the page file is missing (a stale deploy — the API is ahead of the static bundle), return the SAME 404 JSON as the API rather than a 500. """ page = Path(get_settings().static_dir).expanduser().resolve() / _SHARED_PAGE_NAME if not page.is_file(): return JSONResponse( status_code=404, content={"detail": "unknown or revoked share link"} ) return FileResponse(page)