feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row
This commit is contained in:
+67
-15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user