feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row

This commit is contained in:
2026-08-31 05:20:25 -04:00
parent c564e317ed
commit 914097abcf
17 changed files with 1803 additions and 491 deletions
+67 -15
View File
@@ -1,9 +1,22 @@
"""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).
Split gate (phase 55, task 01 — owner-locked A1, superseding the
phase-50 "save/history is admin-only" lock): under ``/api/chats`` the
WRITE surface is **public** (no session) — ``POST`` (create, including
the save-then-share ``share: true`` branch), ``PUT /{chat_id}`` (the
re-Save upsert), ``POST /{chat_id}/share``. WHY: a save is the
visitor's OWN conversation; the row id is an unguessable ``uuid4``, the
same trust model as the phase-51 share token — the id/token IS the
credential (a guest holds the handle to what they just saved, exactly
as a link-holder holds a token). The MANAGEMENT surface is
**admin-only** and keeps the phase-16 :func:`app.core.auth.require_admin`
gate — applied per-route on exactly those four decorators (list,
detail, delete, unshare — the owner's History surface; the
:mod:`app.api.steering` router-wide pattern is untouched). Guest chats
appear in the admin's History (saved by default — phase 55).
Conversations 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``
@@ -27,8 +40,9 @@ PENDING row so it ships in the same INSERT),
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.
``POST /{chat_id}/share`` (public, phase 55) /
``POST /{chat_id}/unshare`` (admin-only — the owner's History
action).
Staleness (phase 53, task 03): every saved row carries the
``sources_meta`` generation it was saved against (``sources_version``,
@@ -82,10 +96,12 @@ from app.schemas import (
UnshareOut,
)
# Phase 55, task 01 (owner-locked A1): NO router-wide gate here — the
# write surface (create / re-Save / share) is public; exactly the four
# management routes below carry ``dependencies=[Depends(require_admin)]``.
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
@@ -151,12 +167,18 @@ def _to_row(row: SavedChat, current_version: int) -> SavedChatRow:
)
@router.get("", response_model=SavedChatList)
@router.get(
"",
response_model=SavedChatList,
dependencies=[Depends(require_admin)], # management surface (phase 55)
)
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
desc``) — the History page's table order, admin-only (the owner's
History surface; phase 55 moved the gate per-route). 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."""
@@ -174,6 +196,10 @@ def create_chat(
) -> SavedChatOut:
"""Store one explicitly saved conversation (201).
PUBLIC (phase 55, task 01) — no session required: the save is the
visitor's own conversation; the unguessable ``uuid4`` row id IS the
credential (the phase-51 token's trust model).
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
@@ -217,13 +243,18 @@ def create_chat(
return _to_out(row, current_version)
@router.get("/{chat_id}", response_model=SavedChatOut)
@router.get(
"/{chat_id}",
response_model=SavedChatOut,
dependencies=[Depends(require_admin)], # management surface (phase 55)
)
def get_chat(
chat_id: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> SavedChatOut:
"""One saved chat, full payload (the ``?chat=<id>`` load); 404 when
the id is unknown. The ``stale`` flag (phase 53) tells the chat
"""One saved chat, full payload (the ``?chat=<id>`` load) —
admin-only (the ``?chat=<id>`` boot restore is the owner's "Open"
action, phase 55 A3); 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)
@@ -240,6 +271,10 @@ def update_chat(
) -> SavedChatOut:
"""Re-Save upsert: full ``messages`` replacement on the same row.
PUBLIC (phase 55, task 01) — no session required (the auto-save
upsert for the visitor's own conversation; same row-id trust model
as :func:`create_chat`).
``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
@@ -269,12 +304,17 @@ def update_chat(
return _to_out(row, current_version)
@router.delete("/{chat_id}", status_code=204)
@router.delete(
"/{chat_id}",
status_code=204,
dependencies=[Depends(require_admin)], # management surface (phase 55)
)
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."""
"""Remove a saved chat — admin-only (the owner's History action,
phase 55); 404 when the id is unknown."""""
row = db.get(SavedChat, chat_id)
if row is None:
raise HTTPException(status_code=404, detail="unknown chat")
@@ -290,6 +330,11 @@ def share_chat(
) -> ShareOut:
"""Turn a saved chat into a public link (phase 51, task 01).
PUBLIC (phase 55, task 01) — no session required: sharing is what
the visitor does with their own conversation (the row-id trust
model of :func:`create_chat`; revoking, by contrast, is the
owner's History action — :func:`unshare_chat` stays admin-only).
Returns ``{"chat_id", "share_url"}`` with ``share_url =
"/shared/<token>"`` (200, idempotent — an existing token is
returned unchanged; a new token is a 128-bit ``uuid4``).
@@ -317,13 +362,20 @@ def share_chat(
return ShareOut(chat_id=row.id, share_url=f"/shared/{token}")
@router.post("/{chat_id}/unshare", response_model=UnshareOut)
@router.post(
"/{chat_id}/unshare",
response_model=UnshareOut,
dependencies=[Depends(require_admin)], # management surface (phase 55)
)
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.
Admin-only (the owner's History action — phase 55 kept the
revocation off the guest's reach, so a guest cannot un-revoke).
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