Files
ai-agent/backend/forms/routes.py
Gabriel Vidal 81c455defa feat(users): scope every guest-reachable route to the caller
A guest sees the conversations they started or were shared (list,
detail, summary, search index, subagents, raw meta, diffs), the forms,
asks and pushes of those, and the projects they were granted. Their
ui-state lives under u.<id>.<key>; the SSE hub filters per subscriber.
Forms record answeredBy. Pushes from a guest's session skip the owner's
webhooks and go to the guest's channels (in-app, email).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:27:23 +02:00

118 lines
4.7 KiB
Python

"""Forms: rich structured questions answered in the PWA."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import schemas
from conversations.catalog import _conv_ids_by_session
from core.http import _json_in_worker, _r
from core.state import State
from forms.service import _create_form
from users import access
from users.caller import Caller, CallerDep
router = APIRouter()
# ── forms: rich structured questions answered in the PWA (forms.py) ──────────
class FormCreateBody(BaseModel):
title: str
fields: list[dict]
description: str | None = None
type: str | None = None
url: str | None = None
sessionId: str | None = None
id: str | None = None
# True: the hub pushes the "📋 <title>" notification itself, deep-linked
# to the conversation with ?form=<id> (the notify CLI's way; the legacy
# skill sent its own push after creating the form).
notify: bool | None = None
# Files the spec references as ``{{asset:<name>}}`` (an html block's image,
# video…), inlined base64 by the CLI so the same payload works through a
# worker's outbox: ``[{name, contentType?, data}]``. Stored like composer
# uploads and substituted by their serve URL before validation.
assets: list[dict] | None = None
class FormSubmitBody(BaseModel):
answers: dict
@router.post("/api/forms", responses=_r(schemas.FormRecord))
def form_create(deps: State, body: FormCreateBody):
"""Create a form (the ask-form skill's entry point) and return it.
The caller then sends its own push notification linking to the
conversation with ``?form=<id>`` and long-polls GET /api/forms/{id}."""
return _create_form(deps, body.model_dump())
@router.get("/api/forms", responses=_r(schemas.FormsResponse))
@_json_in_worker
def forms_list(deps: State, caller: CallerDep, session: str = "", limit: int = 100):
"""Forms, newest first; ``session`` filters to one conversation. Each form
with a known transcript carries ``conversationId`` — its way back to the
conversation it belongs to."""
items = deps.form_store.list(session_id=session or None, limit=limit)
if not caller.is_owner:
items = [f for f in items if f.get("sessionId")
and access.visible(deps, caller, f["sessionId"])]
if any(f.get("sessionId") for f in items):
by_sid = _conv_ids_by_session(deps, )
for f in items:
cid = by_sid.get(f.get("sessionId") or "")
if cid:
f["conversationId"] = cid
return {"forms": items, "count": len(items)}
def _form_visible(deps, caller: Caller, form: dict | None) -> dict:
"""The form, if ``caller`` may see it — a guest only their conversations'."""
if not form or not (caller.is_owner or (
form.get("sessionId")
and access.visible(deps, caller, form["sessionId"]))):
raise HTTPException(404, "unknown form")
return form
@router.get("/api/forms/{form_id}", responses=_r(schemas.FormRecord))
def form_get(deps: State, caller: CallerDep, form_id: str, waitSecs: float = 0):
"""The form's current state; ``waitSecs`` long-polls for a submit."""
if not caller.is_owner:
_form_visible(deps, caller, deps.form_store.get(form_id))
form = (deps.form_store.wait(form_id, waitSecs) if waitSecs > 0
else deps.form_store.get(form_id))
if not form:
raise HTTPException(404, "unknown form")
return form
@router.post("/api/forms/{form_id}/submit", responses=_r(schemas.FormRecord))
def form_submit(deps: State, caller: CallerDep, form_id: str, body: FormSubmitBody):
"""Record the user's answers (from the PWA's form card / modal)."""
_form_visible(deps, caller, deps.form_store.get(form_id))
try:
form = deps.form_store.submit(form_id, body.answers,
by=None if caller.is_owner else caller.id)
except ValueError as e:
raise HTTPException(400, str(e))
if not form:
raise HTTPException(404, "unknown form")
deps.hub.publish({"type": "form", "id": form_id,
"sessionId": form.get("sessionId") or ""})
return form
@router.post("/api/forms/{form_id}/cancel", responses=_r(schemas.FormRecord))
def form_cancel(deps: State, caller: CallerDep, form_id: str):
"""Dismiss a form unanswered (the card's Cancel button, or the agent
giving up on the wait)."""
_form_visible(deps, caller, deps.form_store.get(form_id))
form = deps.form_store.cancel(form_id, by=None if caller.is_owner else caller.id)
if not form:
raise HTTPException(404, "unknown form")
deps.hub.publish({"type": "form", "id": form_id,
"sessionId": form.get("sessionId") or ""})
return form