Files
ai-agent/backend/forms/routes.py
Gabriel Vidal 3d741b5b1e feat(backend): per-user notifications, guest spend on the users list, impersonation
Notifications follow the conversation's audience (users/access.audience):
whoever started it and the people it's shared with, plus the owner once they
follow a guest's conversation (writing in it, or PUT /api/conversation-follow),
minus whoever muted it (meta.follow). Pushes and asks fan out to each of them
on their own channels — the owner on the webhooks, a guest in-app + email —
and the feeds (/api/notifications, /api/notify-log, the phone's /unread)
list only what the caller hears, links rebased to the reader's host. Forms
carry forYou so "waiting for feedback" is per user.

/api/users carries spentUsd (this month), totalSpentUsd and conversations per
guest (users/runs.spend_by_user, the same sum the budget is checked against).

POST /api/users/{id}/impersonate opens a 4-hour guest session for the owner's
browser on the admin host (imp on the record: never extended, not counted in
the guest's sessions); /api/me says impersonating; logout ends it. A spawn
while impersonating skips the "guest started" ping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:16:03 +02:00

124 lines
5.0 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 — and ``forYou``: whether the caller is in its
conversation's audience (the home page's *waiting for feedback* shows
only those; the owner still sees every form)."""
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"])]
for f in items:
sid = f.get("sessionId") or ""
f["forYou"] = (access.hears(caller, deps.meta_store.peek(sid)) if sid
else caller.is_owner)
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