"""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`` so the History's Share column renders 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), ``GET /{chat_id}`` (full payload), ``PUT /{chat_id}`` (re-Save upsert — full ``messages`` replacement, ``title`` replaced only when supplied), ``DELETE /{chat_id}``, and (phase 51, task 01) ``POST /{chat_id}/share`` / ``POST /{chat_id}/unshare`` on this admin-gated router. 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.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) -> SavedChatOut: """The full-payload response shape (create/get/put).""" 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), ) def _to_row(row: SavedChat) -> SavedChatRow: """The list-page row shape (no payloads in the list; ``share_url`` is not a payload — it is the History Share column's data).""" return SavedChatRow( id=row.id, title=row.title, updated_at=row.updated_at, message_count=len(row.messages), share_url=_share_url(row), ) @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.""" rows = db.scalars( select(SavedChat).order_by(SavedChat.updated_at.desc(), SavedChat.id.desc()) ).all() return SavedChatList(chats=[_to_row(row) 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). """ title = (payload.title or "").strip() or _auto_title(payload.messages) 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], ) 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) @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.""" row = db.get(SavedChat, chat_id) if row is None: raise HTTPException(status_code=404, detail="unknown chat") return _to_out(row) @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. """ row = db.get(SavedChat, chat_id) if row is None: raise HTTPException(status_code=404, detail="unknown chat") row.messages = [m.model_dump() for m in payload.messages] title = (payload.title or "").strip() if title: row.title = title db.commit() db.refresh(row) return _to_out(row) @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)