feat(chat): share a chat by link — anonymous read-only /shared/<token> page, share/unshare
This commit is contained in:
+179
-6
@@ -14,20 +14,43 @@ 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}``
|
||||
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}``.
|
||||
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/<token>`` — 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/<token>``): 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 sqlalchemy import select
|
||||
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
|
||||
@@ -38,6 +61,9 @@ from app.schemas import (
|
||||
SavedChatOut,
|
||||
SavedChatRow,
|
||||
SavedChatUpdate,
|
||||
SharedChatOut,
|
||||
ShareOut,
|
||||
UnshareOut,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
@@ -65,6 +91,15 @@ def _auto_title(messages: list[ChatMessage]) -> str:
|
||||
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(
|
||||
@@ -74,16 +109,19 @@ def _to_out(row: SavedChat) -> SavedChatOut:
|
||||
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)."""
|
||||
"""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),
|
||||
)
|
||||
|
||||
|
||||
@@ -110,6 +148,11 @@ def create_chat(
|
||||
text, whitespace-collapsed, truncated to 120 chars (owner-locked
|
||||
convention); a conversation with no user message (defensive) falls
|
||||
back to ``"Chat <id-hex8>"``.
|
||||
|
||||
``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(
|
||||
@@ -121,6 +164,10 @@ def create_chat(
|
||||
# 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:
|
||||
@@ -181,3 +228,129 @@ def delete_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/<token>"`` (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/<token>``.
|
||||
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/<uuid>`` 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/<token>``.
|
||||
|
||||
The page (``frontend/shared.html``, task 03) fetches
|
||||
``GET /api/shared/<token>`` 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)
|
||||
|
||||
+21
-7
@@ -6,9 +6,10 @@ Two layers, one module:
|
||||
append to their asset URLs (``?v=<token>``).
|
||||
* **Response middleware** (``CachingMiddleware`` / ``configure_caching``)
|
||||
— applies the caching behavior at the transport layer: the known
|
||||
HTML pages (``HTML_PAGES``) are always revalidated (``no-cache``) and
|
||||
their local asset references are rewritten to carry ``?v=<token>``;
|
||||
``/assets/*`` is
|
||||
HTML pages (``HTML_PAGES``) and the dynamic share page
|
||||
``/shared/<token>`` (phase 51) are always revalidated (``no-cache``)
|
||||
and their local asset references are rewritten to carry
|
||||
``?v=<token>``; ``/assets/*`` is
|
||||
served ``immutable`` for a year; everything else — all of ``/api/*``,
|
||||
including the SSE chat stream — passes through byte-identical.
|
||||
|
||||
@@ -125,6 +126,11 @@ HTML_PAGES: tuple[str, ...] = (
|
||||
"/tuning.html",
|
||||
"/git-sources.html", # phase 35: the admin git sources page
|
||||
"/history.html", # phase 50: the admin saved-chats page
|
||||
# phase 51: the shared page's STATIC path (the static mount serves
|
||||
# shared.html at /shared.html as well as the real route serves the
|
||||
# dynamic /shared/<token> — both must carry the no-cache + ?v=
|
||||
# contract, so the direct URL can never pin stale assets).
|
||||
"/shared.html",
|
||||
)
|
||||
|
||||
#: Prefix of the versioned static assets (header-only caching; the body is
|
||||
@@ -194,9 +200,10 @@ class CachingMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
* ``/assets/*`` — ``Cache-Control: public, max-age=31536000, immutable``
|
||||
(header only — the body is never read).
|
||||
* the known HTML pages (``HTML_PAGES``) — ``Cache-Control: no-cache``,
|
||||
and (for ``text/html`` bodies) every local asset reference gains
|
||||
``?v=<token>``.
|
||||
* the known HTML pages (``HTML_PAGES``) plus the dynamic share page
|
||||
``/shared/<token>`` (phase 51, by path prefix) —
|
||||
``Cache-Control: no-cache``, and (for ``text/html`` bodies) every
|
||||
local asset reference gains ``?v=<token>``.
|
||||
|
||||
Everything else — all of ``/api/*`` (including the SSE chat stream) —
|
||||
passes through byte-identical: no header changes, the body stream is
|
||||
@@ -213,7 +220,14 @@ class CachingMiddleware(BaseHTTPMiddleware):
|
||||
response.headers["Cache-Control"] = ASSET_CACHE_CONTROL
|
||||
return response
|
||||
|
||||
if path not in HTML_PAGES:
|
||||
# Phase 51: the dynamic share page — ``/shared/<token>`` is a
|
||||
# REAL route (not a static file) serving ``shared.html``, so it
|
||||
# joins the known-page contract by path prefix: no-cache +
|
||||
# ``?v=`` asset rewrite. (``/api/shared/<token>`` — the JSON
|
||||
# read — starts with ``/api/`` and passes through below.)
|
||||
is_known_page = path in HTML_PAGES or path.startswith("/shared/")
|
||||
|
||||
if not is_known_page:
|
||||
# /api/* (incl. SSE), /favicon.ico, unknown paths: untouched.
|
||||
return response
|
||||
|
||||
|
||||
+14
-1
@@ -21,7 +21,15 @@ from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.chat import router as chat_router
|
||||
from app.api.chats import router as chats_router
|
||||
from app.api.chats import (
|
||||
public_router as chats_public_router,
|
||||
)
|
||||
from app.api.chats import (
|
||||
router as chats_router,
|
||||
)
|
||||
from app.api.chats import (
|
||||
shared_page_router as chats_shared_page_router,
|
||||
)
|
||||
from app.api.config import router as config_router
|
||||
from app.api.docs import router as docs_router
|
||||
from app.api.git_sources import router as git_sources_router
|
||||
@@ -73,6 +81,11 @@ def create_app() -> FastAPI:
|
||||
app.include_router(steering_router, prefix="/api")
|
||||
app.include_router(sync_router, prefix="/api")
|
||||
app.include_router(chats_router, prefix="/api")
|
||||
# Phase 51: the anonymous shared-chat read — NO admin dependency.
|
||||
# /api/shared/<token> is the JSON snapshot; /shared/<token> (the
|
||||
# page route below, registered without a prefix) is the page.
|
||||
app.include_router(chats_public_router, prefix="/api")
|
||||
app.include_router(chats_shared_page_router) # no prefix — /shared/<token>
|
||||
|
||||
# Cache busting (phase 33): the five HTML pages revalidate (no-cache)
|
||||
# with ?v=<token> asset refs; /assets/* becomes immutable for a year.
|
||||
|
||||
+13
-3
@@ -21,7 +21,9 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
|
||||
explicitly Saved conversation (auto-``title`` +
|
||||
the ``bor.chat.v1`` message list as JSONB, phase
|
||||
14 shape) — phase 50; ``/api/chat`` stays
|
||||
stateless.
|
||||
stateless; ``share_token`` (NULL = private,
|
||||
``uuid4`` = publicly readable at
|
||||
``/shared/<token>``) — phase 51.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -178,8 +180,7 @@ class SavedChat(Base):
|
||||
saved. ``messages`` holds 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. No share-related columns here —
|
||||
phase 51 adds ``share_token`` in migration 0009.
|
||||
``renderStoredMessage`` path.
|
||||
"""
|
||||
|
||||
__tablename__ = "saved_chats"
|
||||
@@ -193,6 +194,15 @@ class SavedChat(Base):
|
||||
#: tools?, stopped?}``); the API always supplies a list, so no
|
||||
#: default is needed.
|
||||
messages: Mapped[list] = mapped_column(JSONB)
|
||||
#: Anonymous share link (phase 51): a 128-bit ``uuid4`` token; when
|
||||
#: set, the chat is publicly readable at ``/shared/<token>`` without
|
||||
#: any admin session, and unsharing (token → NULL) revokes it.
|
||||
#: Unique — Postgres treats NULLs as distinct under a unique index
|
||||
#: (the ``git_sources.path`` precedent, phase 38), so any number of
|
||||
#: unshared chats coexist while two identical tokens can never exist.
|
||||
share_token: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), unique=True, nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
|
||||
+99
-6
@@ -3,9 +3,16 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
SerializerFunctionWrapHandler,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
)
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
@@ -324,16 +331,26 @@ class ChatMessage(BaseModel):
|
||||
|
||||
|
||||
class SavedChatCreate(BaseModel):
|
||||
"""``POST /api/chats`` body (phase 50, task 02).
|
||||
"""``POST /api/chats`` body (phase 50, task 02; ``share``, phase 51
|
||||
task 02).
|
||||
|
||||
``title`` is optional: when absent or blank the API auto-titles the
|
||||
row (the first user message's text, whitespace-collapsed, truncated
|
||||
to 120 chars — the owner-locked convention). ``messages`` must be
|
||||
non-empty — a saved chat with nothing to restore is meaningless.
|
||||
|
||||
``share`` (phase 51, owner-locked 2026-08-29): when true, the row is
|
||||
shared in the SAME commit — ``share_token = uuid.uuid4()`` is set on
|
||||
the fresh row before the INSERT, so one request saves AND shares
|
||||
(the chat page's Share button on an unsaved conversation, the
|
||||
save-then-share contract). The response then carries ``share_url``
|
||||
(see :class:`SavedChatOut`). Default false — a plain Save is
|
||||
unchanged by phase 51.
|
||||
"""
|
||||
|
||||
title: str | None = Field(default=None, max_length=500)
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
share: bool = False
|
||||
|
||||
|
||||
class SavedChatUpdate(BaseModel):
|
||||
@@ -349,11 +366,35 @@ class SavedChatUpdate(BaseModel):
|
||||
messages: list[ChatMessage] = Field(min_length=1)
|
||||
|
||||
|
||||
def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
"""The ``share_url`` omission rule (phase 51, task 02): ``None`` →
|
||||
ABSENT from the JSON (not ``"share_url": null``) — an unshared chat
|
||||
exposes no share surface at all, and the History column renders the
|
||||
unshared state from the key's absence.
|
||||
|
||||
A ``mode="wrap"`` model serializer: the default (recursive) dump runs
|
||||
first, then only the TOP-LEVEL key is dropped when null. The
|
||||
recursion matters — a route-level ``response_model_exclude_none``
|
||||
would also drop the nested ``ChatMessage`` nulls (``sources: null``
|
||||
and friends), which the byte-identical round-trip contract (phase
|
||||
50) forbids.
|
||||
"""
|
||||
data = handler(model)
|
||||
if data.get("share_url") is None:
|
||||
data.pop("share_url", None)
|
||||
return data
|
||||
|
||||
|
||||
class SavedChatOut(BaseModel):
|
||||
"""One saved chat, full payload (create/get/put response, phase 50).
|
||||
"""One saved chat, full payload (create/get/put response, phase 50;
|
||||
``share_url``, phase 51 task 02).
|
||||
|
||||
``messages`` round-trips the ``bor.chat.v1`` record list losslessly
|
||||
— the restore path is pixel-identical by construction.
|
||||
|
||||
``share_url`` (phase 51): ``"/shared/<token>"`` while the chat is
|
||||
shared, ABSENT from the JSON when unshared (``None`` → dropped by
|
||||
:func:`_drop_absent_share_url` — no ``null`` in the wire shape).
|
||||
"""
|
||||
|
||||
id: uuid.UUID
|
||||
@@ -362,19 +403,33 @@ class SavedChatOut(BaseModel):
|
||||
updated_at: datetime
|
||||
message_count: int
|
||||
messages: list[ChatMessage]
|
||||
share_url: str | None = None
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
return _drop_absent_share_url(self, handler)
|
||||
|
||||
|
||||
class SavedChatRow(BaseModel):
|
||||
"""One row of ``GET /api/chats`` (the History page's list shape).
|
||||
"""One row of ``GET /api/chats`` (the History page's list shape,
|
||||
phase 50; ``share_url``, phase 51 task 02).
|
||||
|
||||
No payloads in the list — the row carries only what the table needs
|
||||
(Title, Messages count, Updated).
|
||||
(Title, Messages count, Updated). ``share_url`` is populated here so
|
||||
the History page's Share column renders straight from ``GET
|
||||
/api/chats`` — no second fetch per row (``None`` → absent, the same
|
||||
omission rule as :class:`SavedChatOut`).
|
||||
"""
|
||||
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
updated_at: datetime
|
||||
message_count: int
|
||||
share_url: str | None = None
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||
return _drop_absent_share_url(self, handler)
|
||||
|
||||
|
||||
class SavedChatList(BaseModel):
|
||||
@@ -382,3 +437,41 @@ class SavedChatList(BaseModel):
|
||||
(``updated_at desc, id desc``)."""
|
||||
|
||||
chats: list[SavedChatRow]
|
||||
|
||||
|
||||
class SharedChatOut(BaseModel):
|
||||
"""``GET /api/shared/{token}`` body (phase 51, task 01) — the PUBLIC
|
||||
read shape of a shared chat.
|
||||
|
||||
Deliberately minimal: ``title`` + ``messages`` only. No id, no
|
||||
timestamps, no token, no ``message_count`` — a shared chat is a
|
||||
content snapshot, not a handle: nothing in the body can be turned
|
||||
back into an admin-surface request, and the token itself never
|
||||
round-trips (it is the URL, not data).
|
||||
"""
|
||||
|
||||
title: str
|
||||
messages: list[ChatMessage]
|
||||
|
||||
|
||||
class ShareOut(BaseModel):
|
||||
"""``POST /api/chats/{chat_id}/share`` response (phase 51, task 01).
|
||||
|
||||
``share_url`` is the path (``/shared/<token>``) the UI copies into
|
||||
the clipboard — the owner's own origin supplies the scheme/host.
|
||||
Idempotent: a re-share returns the existing, unchanged token.
|
||||
"""
|
||||
|
||||
chat_id: uuid.UUID
|
||||
share_url: str
|
||||
|
||||
|
||||
class UnshareOut(BaseModel):
|
||||
"""``POST /api/chats/{chat_id}/unshare`` response (phase 51, task 01).
|
||||
|
||||
``shared: false`` is reported unconditionally — the endpoint is
|
||||
idempotent, so an already-unshared chat unshares cleanly (200).
|
||||
"""
|
||||
|
||||
chat_id: uuid.UUID
|
||||
shared: bool
|
||||
|
||||
Reference in New Issue
Block a user