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>
74 lines
2.5 KiB
Python
74 lines
2.5 KiB
Python
"""Forms: rich structured questions answered in the PWA (``forms.py``)."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Literal, Optional
|
|
|
|
from .base import Schema
|
|
|
|
__all__ = [
|
|
"FormFieldOption",
|
|
"FormField",
|
|
"FormRecord",
|
|
"FormsResponse",
|
|
]
|
|
|
|
|
|
class FormFieldOption(Schema):
|
|
value: str
|
|
label: str
|
|
|
|
|
|
class FormField(Schema):
|
|
"""One field of a form spec (see forms.FIELD_TYPES)."""
|
|
key: str
|
|
label: str
|
|
type: Literal["text", "textarea", "number", "select", "multiselect",
|
|
"radio", "checkbox", "slider", "file", "date",
|
|
"markdown", "html"]
|
|
required: bool
|
|
text: Optional[str] = None # markdown: the block's content
|
|
html: Optional[str] = None # html: raw markup of the block
|
|
sandbox: Optional[bool] = None # html: render in a sandboxed iframe
|
|
height: Optional[int] = None # html: fixed iframe height (px)
|
|
placeholder: Optional[str] = None
|
|
help: Optional[str] = None
|
|
accept: Optional[str] = None # file: input accept filter
|
|
options: Optional[list[FormFieldOption]] = None
|
|
other: Optional[bool] = None # option fields: an "Other…" free-text choice
|
|
min: Optional[float] = None
|
|
max: Optional[float] = None
|
|
step: Optional[float] = None
|
|
multiple: Optional[bool] = None # file: allow several
|
|
default: Optional[Any] = None
|
|
group: Optional[str] = None # the viewer page the field shows on
|
|
catchAll: Optional[bool] = None # the trailing "Anything else?" field
|
|
|
|
|
|
class FormRecord(Schema):
|
|
"""A form with its lifecycle state; ``answers`` keyed by field key."""
|
|
id: str
|
|
title: str
|
|
description: str
|
|
type: str
|
|
url: str
|
|
sessionId: str
|
|
at: str
|
|
fields: list[FormField]
|
|
status: Literal["pending", "submitted", "cancelled"]
|
|
answers: Optional[dict[str, Any]] = None
|
|
submittedAt: Optional[str] = None
|
|
cancelledAt: Optional[str] = None
|
|
# The guest who answered (users.json id); None = the owner.
|
|
answeredBy: Optional[str] = None
|
|
# Transcript id for `sessionId` (joined at list time) — the /conversation/…
|
|
# link back to the conversation the form belongs to.
|
|
conversationId: Optional[str] = None
|
|
# Is the caller in the audience of the form's conversation (list only)? The
|
|
# home page's "waiting for feedback" shows only these.
|
|
forYou: Optional[bool] = None
|
|
|
|
|
|
class FormsResponse(Schema):
|
|
forms: list[FormRecord]
|
|
count: Optional[int] = None
|