"""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), ``POST`` (create — auto-title from the first question when no ``title`` is supplied), ``GET /{chat_id}`` (full payload), ``PUT /{chat_id}`` (re-Save upsert — full ``messages`` replacement, ``title`` replaced only when supplied), ``DELETE /{chat_id}``. """ from __future__ import annotations import uuid from fastapi import APIRouter, Depends, HTTPException, Response from sqlalchemy import select from sqlalchemy.orm import Session 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, ) 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 _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], ) def _to_row(row: SavedChat) -> SavedChatRow: """The list-page row shape (no payloads in the list).""" return SavedChatRow( id=row.id, title=row.title, updated_at=row.updated_at, message_count=len(row.messages), ) @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 "``. """ 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], ) 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)