This commit is contained in:
debu-2 2026-10-02 22:39:04 +05:30
parent d272ae0bd3
commit ff01efe4bb
17 changed files with 1378 additions and 21 deletions

View file

@ -22,6 +22,7 @@ class SendAgentMessagePayload(BaseModel):
template: str = Field(min_length=2, max_length=64) template: str = Field(min_length=2, max_length=64)
to: str = Field(min_length=10, max_length=20) to: str = Field(min_length=10, max_length=20)
customer_name: str = Field(min_length=1, max_length=120) customer_name: str = Field(min_length=1, max_length=120)
report_group: str = Field(min_length=3, max_length=50)
report_id: str | None = Field(default=None, max_length=120) report_id: str | None = Field(default=None, max_length=120)
@ -72,6 +73,18 @@ def require_subscription(db: Session, user: UserAccount, template: AgentMessageT
return subscription return subscription
def _validate_report_group(report_group: str) -> str:
cleaned = report_group.strip().lower()
if not cleaned or len(cleaned) > 50:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid report_group")
if not re.fullmatch(r"[a-z][a-z0-9-]{1,48}[a-z0-9]", cleaned) or "--" in cleaned:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="report_group must be 3-50 chars: lowercase letters, numbers, hyphens",
)
return cleaned
def _validate_report_id(report_id: str) -> str: def _validate_report_id(report_id: str) -> str:
cleaned = report_id.strip() cleaned = report_id.strip()
if not cleaned or len(cleaned) > 120: if not cleaned or len(cleaned) > 120:
@ -84,7 +97,12 @@ def _validate_report_id(report_id: str) -> str:
return cleaned return cleaned
def _build_template_components(template: AgentMessageTemplate, customer_name: str, report_id: str) -> list[dict]: def _build_template_components(
template: AgentMessageTemplate,
customer_name: str,
report_group: str,
report_id: str,
) -> list[dict]:
components: list[dict] = [ components: list[dict] = [
{ {
"type": "body", "type": "body",
@ -103,7 +121,7 @@ def _build_template_components(template: AgentMessageTemplate, customer_name: st
"type": "button", "type": "button",
"sub_type": "url", "sub_type": "url",
"index": "0", "index": "0",
"parameters": [{"type": "text", "text": report_id}], "parameters": [{"type": "text", "text": f"{report_group}/{report_id}"}],
} }
) )
return components return components
@ -128,8 +146,9 @@ async def send_agent_template_message(
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
report_group = _validate_report_group(payload.report_group)
report_id = _validate_report_id(payload.report_id or f"run-{secrets.token_hex(3)}") report_id = _validate_report_id(payload.report_id or f"run-{secrets.token_hex(3)}")
components = _build_template_components(template, payload.customer_name, report_id) components = _build_template_components(template, payload.customer_name, report_group, report_id)
client = _whatsapp_client(db) client = _whatsapp_client(db)
try: try:
@ -149,6 +168,7 @@ async def send_agent_template_message(
recipient=recipient, recipient=recipient,
parameters={ parameters={
"customer_name": payload.customer_name.strip(), "customer_name": payload.customer_name.strip(),
"report_group": report_group,
"report_id": report_id, "report_id": report_id,
}, },
wa_message_id=wa_message_id, wa_message_id=wa_message_id,
@ -159,12 +179,17 @@ async def send_agent_template_message(
db.commit() db.commit()
db.refresh(log) db.refresh(log)
report_url = f"{template.button_url_base.rstrip('/')}/{report_id}" if template.button_url_base else None report_url = (
f"{template.button_url_base.rstrip('/')}/{report_group}/{report_id}"
if template.button_url_base
else None
)
return { return {
"success": True, "success": True,
"template": template.slug, "template": template.slug,
"to": recipient, "to": recipient,
"customer_name": payload.customer_name.strip(), "customer_name": payload.customer_name.strip(),
"report_group": report_group,
"report_id": report_id, "report_id": report_id,
"report_url": report_url, "report_url": report_url,
"wa_message_id": wa_message_id, "wa_message_id": wa_message_id,
@ -201,6 +226,7 @@ async def run_console_template_test(
*, *,
to: str, to: str,
customer_name: str, customer_name: str,
report_group: str,
report_id: str | None = None, report_id: str | None = None,
) -> dict: ) -> dict:
require_subscription(db, user, template) require_subscription(db, user, template)
@ -215,6 +241,7 @@ async def run_console_template_test(
template=template.slug, template=template.slug,
to=to, to=to,
customer_name=customer_name, customer_name=customer_name,
report_group=report_group,
report_id=report_id, report_id=report_id,
) )
result = await send_agent_template_message(db, user, payload, source="console_test") result = await send_agent_template_message(db, user, payload, source="console_test")
@ -250,12 +277,14 @@ def build_agent_message_docs(template: AgentMessageTemplate) -> dict:
"template": f"Template slug. Use `{template.slug}`.", "template": f"Template slug. Use `{template.slug}`.",
"to": "Recipient WhatsApp number with country code, e.g. 919149154337", "to": "Recipient WhatsApp number with country code, e.g. 919149154337",
"customer_name": "Recipient name inserted into the template body", "customer_name": "Recipient name inserted into the template body",
"report_id": "Optional unique report id for the View Report button URL suffix", "report_group": "Urmiport report group slug used in the hosted report URL",
"report_id": "Optional report postfix; auto-generated when omitted",
}, },
"example": { "example": {
"template": template.slug, "template": template.slug,
"to": "919149154337", "to": "919149154337",
"customer_name": "Suyash", "customer_name": "Suyash",
"report_group": "acme-agent-runs",
"report_id": "run-abc123", "report_id": "run-abc123",
}, },
"response_example": { "response_example": {
@ -263,8 +292,9 @@ def build_agent_message_docs(template: AgentMessageTemplate) -> dict:
"template": template.slug, "template": template.slug,
"to": "919149154337", "to": "919149154337",
"customer_name": "Suyash", "customer_name": "Suyash",
"report_group": "acme-agent-runs",
"report_id": "run-abc123", "report_id": "run-abc123",
"report_url": f"{template.button_url_base or ''}run-abc123", "report_url": f"{template.button_url_base or ''}acme-agent-runs/run-abc123",
"wa_message_id": "wamid.HBg...", "wa_message_id": "wamid.HBg...",
"dispatch_id": "00000000-0000-0000-0000-000000000001", "dispatch_id": "00000000-0000-0000-0000-000000000001",
}, },
@ -272,6 +302,7 @@ def build_agent_message_docs(template: AgentMessageTemplate) -> dict:
"notes": [ "notes": [
"Subscribe to the template in the Urmi console before calling this API.", "Subscribe to the template in the Urmi console before calling this API.",
"Messages are sent from the Urmi AI WhatsApp business number on your behalf.", "Messages are sent from the Urmi AI WhatsApp business number on your behalf.",
"If report_id is omitted, Urmi generates a unique id automatically.", "Create the report group in Console → Agent → Urmiport before sending.",
"If report_id is omitted, Urmi generates a unique postfix automatically.",
], ],
} }

View file

@ -28,11 +28,18 @@ DEFAULT_AGENT_TEMPLATES = [
"description": "Recipient display name shown in the greeting", "description": "Recipient display name shown in the greeting",
"example": "Suyash", "example": "Suyash",
}, },
{
"name": "report_group",
"type": "string",
"required": True,
"description": "Urmiport report group slug used in the hosted report URL",
"example": "acme-agent-runs",
},
{ {
"name": "report_id", "name": "report_id",
"type": "string", "type": "string",
"required": True, "required": True,
"description": "Unique report identifier appended to the View Report button URL", "description": "Report postfix appended after the report group in the View Report button URL",
"example": "run-abc123", "example": "run-abc123",
}, },
], ],

View file

@ -28,6 +28,8 @@ from .routes_voice import router as voice_router
from .routes_whatsapp import router as whatsapp_router from .routes_whatsapp import router as whatsapp_router
from .routes_agent_templates import router as agent_templates_router from .routes_agent_templates import router as agent_templates_router
from .routes_agent_messages import router as agent_messages_router from .routes_agent_messages import router as agent_messages_router
from .routes_urmiport import router as urmiport_router
from .routes_urmiport_api import router as urmiport_api_router
from .agent_templates_seed import seed_agent_message_templates from .agent_templates_seed import seed_agent_message_templates
@ -95,6 +97,8 @@ app.include_router(voice_router)
app.include_router(whatsapp_router) app.include_router(whatsapp_router)
app.include_router(agent_templates_router) app.include_router(agent_templates_router)
app.include_router(agent_messages_router) app.include_router(agent_messages_router)
app.include_router(urmiport_router)
app.include_router(urmiport_api_router)
app.include_router(admin_router) app.include_router(admin_router)

View file

@ -109,6 +109,12 @@ class UserAccount(Base):
agent_message_dispatches: Mapped[list["AgentMessageDispatchLog"]] = relationship( agent_message_dispatches: Mapped[list["AgentMessageDispatchLog"]] = relationship(
back_populates="user", cascade="all, delete-orphan" back_populates="user", cascade="all, delete-orphan"
) )
urmiport_report_groups: Mapped[list["UrmiportReportGroup"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
urmiport_reports: Mapped[list["UrmiportReport"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
class PasswordCredential(Base): class PasswordCredential(Base):
@ -609,6 +615,49 @@ class AgentMessageDispatchLog(Base):
template: Mapped[AgentMessageTemplate] = relationship() template: Mapped[AgentMessageTemplate] = relationship()
class UrmiportReportGroup(Base):
__tablename__ = "urmiport_report_groups"
__table_args__ = (UniqueConstraint("user_id", "slug", name="uq_urmiport_group_user_slug"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
slug: Mapped[str] = mapped_column(String(50), index=True)
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
default_access: Mapped[str] = mapped_column(String(16), default="public", index=True)
password_hash: Mapped[str | None] = mapped_column(String(500), nullable=True)
id_mode: Mapped[str] = mapped_column(String(16), default="auto")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped[UserAccount] = relationship(back_populates="urmiport_report_groups")
reports: Mapped[list["UrmiportReport"]] = relationship(
back_populates="group", cascade="all, delete-orphan"
)
class UrmiportReport(Base):
__tablename__ = "urmiport_reports"
__table_args__ = (UniqueConstraint("group_id", "report_id", name="uq_urmiport_group_report_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
)
group_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("urmiport_report_groups.id", ondelete="CASCADE"), index=True
)
report_id: Mapped[str] = mapped_column(String(120), index=True)
html_body: Mapped[str] = mapped_column(Text)
access_mode: Mapped[str] = mapped_column(String(16), default="public", index=True)
password_hash: Mapped[str | None] = mapped_column(String(500), 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()
)
user: Mapped[UserAccount] = relationship(back_populates="urmiport_reports")
group: Mapped[UrmiportReportGroup] = relationship(back_populates="reports")
class PlatformSetting(Base): class PlatformSetting(Base):
"""Runtime overrides for platform credentials (admin-editable).""" """Runtime overrides for platform credentials (admin-editable)."""

View file

@ -45,10 +45,16 @@ def _explorer_doc_for_template(template: AgentMessageTemplate) -> dict:
"description": "Recipient name shown in the template body", "description": "Recipient name shown in the template body",
"example": "Suyash", "example": "Suyash",
}, },
"report_group": {
"type": "string",
"required": True,
"description": "Urmiport report group slug used in the hosted report URL",
"example": "acme-agent-runs",
},
"report_id": { "report_id": {
"type": "string", "type": "string",
"required": False, "required": False,
"description": "Report id suffix for the dynamic View Report button URL", "description": "Report postfix; auto-generated when omitted",
"example": "run-abc123", "example": "run-abc123",
}, },
}, },
@ -60,8 +66,9 @@ def _explorer_doc_for_template(template: AgentMessageTemplate) -> dict:
"template": template.slug, "template": template.slug,
"to": "919149154337", "to": "919149154337",
"customer_name": "Suyash", "customer_name": "Suyash",
"report_group": "acme-agent-runs",
"report_id": "run-abc123", "report_id": "run-abc123",
"report_url": f"{template.button_url_base or ''}run-abc123", "report_url": f"{template.button_url_base or ''}acme-agent-runs/run-abc123",
"wa_message_id": "wamid.HBg...", "wa_message_id": "wamid.HBg...",
"dispatch_id": "00000000-0000-0000-0000-000000000001", "dispatch_id": "00000000-0000-0000-0000-000000000001",
}, },

View file

@ -20,6 +20,7 @@ router = APIRouter(prefix="/api/v1/agent-templates", tags=["agent-templates"])
class AgentTemplateTestRequest(BaseModel): class AgentTemplateTestRequest(BaseModel):
to: str = Field(min_length=10, max_length=20) to: str = Field(min_length=10, max_length=20)
customer_name: str = Field(min_length=1, max_length=120) customer_name: str = Field(min_length=1, max_length=120)
report_group: str = Field(min_length=3, max_length=50)
report_id: str | None = Field(default=None, max_length=120) report_id: str | None = Field(default=None, max_length=120)
@ -140,5 +141,6 @@ async def test_agent_template(
template, template,
to=payload.to, to=payload.to,
customer_name=payload.customer_name, customer_name=payload.customer_name,
report_group=payload.report_group,
report_id=payload.report_id, report_id=payload.report_id,
) )

View file

@ -0,0 +1,180 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .auth import current_user
from .database import get_db
from .models import UrmiportReport, UrmiportReportGroup, UserAccount
from .urmiport_service import (
CreateUrmiportGroupPayload,
UpdateUrmiportGroupPayload,
build_urmiport_api_docs,
get_group_by_slug,
get_report,
get_user_group,
hash_password,
normalize_group_slug,
serialize_group,
serialize_report,
verify_password,
)
router = APIRouter(prefix="/api/v1/urmiport", tags=["urmiport"])
class UnlockReportRequest(BaseModel):
password: str = Field(min_length=1, max_length=120)
@router.get("/docs")
def urmiport_docs() -> dict:
return build_urmiport_api_docs()
@router.get("/groups")
def list_report_groups(
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
groups = db.scalars(
select(UrmiportReportGroup)
.where(UrmiportReportGroup.user_id == user.id)
.order_by(UrmiportReportGroup.created_at.desc())
).all()
counts = dict(
db.execute(
select(UrmiportReport.group_id, func.count(UrmiportReport.id))
.where(UrmiportReport.user_id == user.id)
.group_by(UrmiportReport.group_id)
).all()
)
return [serialize_group(group, counts.get(group.id, 0)) for group in groups]
@router.post("/groups")
def create_report_group(
payload: CreateUrmiportGroupPayload,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
try:
slug = normalize_group_slug(payload.report_group)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
existing = db.scalar(
select(UrmiportReportGroup).where(
UrmiportReportGroup.user_id == user.id,
UrmiportReportGroup.slug == slug,
)
)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Report group already exists")
if payload.default_access == "password" and not payload.password:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="password is required")
group = UrmiportReportGroup(
user_id=user.id,
slug=slug,
display_name=payload.display_name or slug,
default_access=payload.default_access,
password_hash=hash_password(payload.password) if payload.default_access == "password" else None,
id_mode=payload.id_mode,
)
db.add(group)
db.commit()
db.refresh(group)
return serialize_group(group, 0)
@router.patch("/groups/{slug}")
def update_report_group(
slug: str,
payload: UpdateUrmiportGroupPayload,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
group = get_user_group(db, user, slug)
if payload.display_name is not None:
group.display_name = payload.display_name.strip() or group.slug
if payload.default_access is not None:
group.default_access = payload.default_access
if payload.default_access == "password":
if payload.password:
group.password_hash = hash_password(payload.password)
elif not group.password_hash:
raise HTTPException(status_code=422, detail="password is required")
else:
group.password_hash = None
elif payload.password:
group.password_hash = hash_password(payload.password)
if payload.id_mode is not None:
group.id_mode = payload.id_mode
db.commit()
db.refresh(group)
count = db.scalar(
select(func.count(UrmiportReport.id)).where(UrmiportReport.group_id == group.id)
) or 0
return serialize_group(group, count)
@router.delete("/groups/{slug}")
def delete_report_group(
slug: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> dict:
group = get_user_group(db, user, slug)
db.delete(group)
db.commit()
return {"success": True}
@router.get("/groups/{slug}/reports")
def list_group_reports(
slug: str,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_user),
) -> list[dict]:
group = get_user_group(db, user, slug)
reports = db.scalars(
select(UrmiportReport)
.where(UrmiportReport.group_id == group.id)
.order_by(UrmiportReport.updated_at.desc())
).all()
return [serialize_report(report, group) for report in reports]
@router.get("/view/{group_slug}/{report_id}")
def view_report_metadata(
group_slug: str,
report_id: str,
db: Session = Depends(get_db),
) -> dict:
group = get_group_by_slug(db, group_slug)
report = get_report(db, group, report_id)
return {
"report_group": group.slug,
"report_id": report.report_id,
"access": report.access_mode,
"requires_password": report.access_mode == "password",
"html": report.html_body if report.access_mode == "public" else None,
}
@router.post("/view/{group_slug}/{report_id}/unlock")
def unlock_report(
group_slug: str,
report_id: str,
payload: UnlockReportRequest,
db: Session = Depends(get_db),
) -> dict:
group = get_group_by_slug(db, group_slug)
report = get_report(db, group, report_id)
if report.access_mode != "password":
return {"html": report.html_body}
if not verify_password(payload.password, report.password_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid password")
return {"html": report.html_body}

View file

@ -0,0 +1,23 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .api_key_auth import current_api_user
from .database import get_db
from .models import UserAccount
from .urmiport_service import UpsertUrmiportReportPayload, build_urmiport_api_docs, upsert_report_for_user
router = APIRouter(prefix="/api/v1/urmiport", tags=["urmiport-api"])
@router.get("/reports/docs")
def urmiport_reports_api_docs() -> dict:
return build_urmiport_api_docs()
@router.post("/reports")
def publish_report(
payload: UpsertUrmiportReportPayload,
db: Session = Depends(get_db),
user: UserAccount = Depends(current_api_user),
) -> dict:
return upsert_report_for_user(db, user, payload)

View file

@ -0,0 +1,300 @@
import re
import secrets
from datetime import datetime, timezone
from fastapi import HTTPException, status
from pydantic import BaseModel, Field
from pwdlib import PasswordHash
from sqlalchemy import select
from sqlalchemy.orm import Session
from .models import UrmiportReport, UrmiportReportGroup, UserAccount
password_hasher = PasswordHash.recommended()
GROUP_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{1,48}[a-z0-9]$")
REPORT_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}$")
class UpsertUrmiportReportPayload(BaseModel):
report_group: str = Field(min_length=3, max_length=50)
report_id: str | None = Field(default=None, max_length=120)
body: str = Field(min_length=1)
access: str = Field(default="public", pattern="^(public|password)$")
password: str | None = Field(default=None, max_length=120)
class CreateUrmiportGroupPayload(BaseModel):
report_group: str = Field(min_length=3, max_length=50)
display_name: str | None = Field(default=None, max_length=120)
default_access: str = Field(default="public", pattern="^(public|password)$")
password: str | None = Field(default=None, max_length=120)
id_mode: str = Field(default="auto", pattern="^(auto|manual)$")
class UpdateUrmiportGroupPayload(BaseModel):
display_name: str | None = Field(default=None, max_length=120)
default_access: str | None = Field(default=None, pattern="^(public|password)$")
password: str | None = Field(default=None, max_length=120)
id_mode: str | None = Field(default=None, pattern="^(auto|manual)$")
def normalize_group_slug(value: str) -> str:
slug = value.strip().lower()
if not GROUP_SLUG_RE.match(slug) or "--" in slug:
raise ValueError("Report group must be 3-50 chars: lowercase letters, numbers, hyphens")
return slug
def normalize_report_id(value: str) -> str:
report_id = value.strip()
if not REPORT_ID_RE.match(report_id):
raise ValueError("Report id may contain letters, numbers, dots, underscores, and hyphens")
return report_id
def hash_password(value: str | None) -> str | None:
if not value:
return None
return password_hasher.hash(value)
def verify_password(value: str, hashed: str | None) -> bool:
if not hashed:
return False
return password_hasher.verify(value, hashed)
def report_public_url(group_slug: str, report_id: str) -> str:
return f"https://urmiai.com/reports/{group_slug}/{report_id}"
def serialize_group(group: UrmiportReportGroup, report_count: int = 0) -> dict:
return {
"id": str(group.id),
"report_group": group.slug,
"display_name": group.display_name,
"default_access": group.default_access,
"password_protected": group.default_access == "password",
"id_mode": group.id_mode,
"report_count": report_count,
"url_pattern": f"https://urmiai.com/reports/{group.slug}/{{report_id}}",
"created_at": group.created_at,
}
def serialize_report(report: UrmiportReport, group: UrmiportReportGroup) -> dict:
return {
"id": str(report.id),
"report_group": group.slug,
"report_id": report.report_id,
"access": report.access_mode,
"password_protected": report.access_mode == "password",
"url": report_public_url(group.slug, report.report_id),
"updated_at": report.updated_at,
"created_at": report.created_at,
}
def get_user_group(db: Session, user: UserAccount, slug: str) -> UrmiportReportGroup:
normalized = normalize_group_slug(slug)
group = db.scalar(
select(UrmiportReportGroup).where(
UrmiportReportGroup.user_id == user.id,
UrmiportReportGroup.slug == normalized,
)
)
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report group not found")
return group
def get_group_by_slug(db: Session, slug: str) -> UrmiportReportGroup:
try:
normalized = normalize_group_slug(slug)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report group not found") from exc
group = db.scalar(select(UrmiportReportGroup).where(UrmiportReportGroup.slug == normalized))
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report group not found")
return group
def get_report(db: Session, group: UrmiportReportGroup, report_id: str) -> UrmiportReport:
try:
normalized = normalize_report_id(report_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report not found") from exc
report = db.scalar(
select(UrmiportReport).where(
UrmiportReport.group_id == group.id,
UrmiportReport.report_id == normalized,
)
)
if not report:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report not found")
return report
def generate_report_id(db: Session, group: UrmiportReportGroup) -> str:
for _ in range(12):
candidate = f"run-{secrets.token_hex(3)}"
exists = db.scalar(
select(UrmiportReport.id).where(
UrmiportReport.group_id == group.id,
UrmiportReport.report_id == candidate,
)
)
if not exists:
return candidate
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Unable to generate report id")
def upsert_report_for_user(
db: Session,
user: UserAccount,
payload: UpsertUrmiportReportPayload,
) -> dict:
try:
group_slug = normalize_group_slug(payload.report_group)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
group = db.scalar(
select(UrmiportReportGroup).where(
UrmiportReportGroup.user_id == user.id,
UrmiportReportGroup.slug == group_slug,
)
)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report group not found. Create it in Console → Agent → Urmiport first.",
)
if payload.report_id:
try:
report_id = normalize_report_id(payload.report_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
elif group.id_mode == "manual":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="report_id is required for this report group",
)
else:
report_id = generate_report_id(db, group)
access_mode = payload.access
password_hash_value = (
hash_password(payload.password)
if access_mode == "password"
else None
)
if access_mode == "password" and not password_hash_value and not group.password_hash:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="password is required when access is password",
)
if access_mode == "password" and not password_hash_value:
password_hash_value = group.password_hash
existing = db.scalar(
select(UrmiportReport).where(
UrmiportReport.group_id == group.id,
UrmiportReport.report_id == report_id,
)
)
if existing:
existing.html_body = payload.body
existing.access_mode = access_mode
existing.password_hash = password_hash_value
existing.updated_at = datetime.now(timezone.utc)
report = existing
else:
report = UrmiportReport(
user_id=user.id,
group_id=group.id,
report_id=report_id,
html_body=payload.body,
access_mode=access_mode,
password_hash=password_hash_value,
)
db.add(report)
db.commit()
db.refresh(report)
result = serialize_report(report, group)
result["success"] = True
return result
def build_urmiport_api_docs() -> dict:
return {
"id": "urmiport-upsert-report",
"method": "POST",
"path": "/api/v1/urmiport/reports",
"title": "Publish Urmiport HTML report",
"summary": (
"Create or update a hosted HTML report. Reports are served at "
"https://urmiai.com/reports/{report_group}/{report_id} without Urmi branding."
),
"authentication": {
"type": "api_key",
"header": "Authorization",
"format": "Bearer urmi_live_<key>",
"alternate_header": "X-Api-Key",
},
"request_body": {
"report_group": {
"type": "string",
"required": True,
"description": "Report group slug created in Console → Agent → Urmiport",
"example": "acme-agent-runs",
},
"report_id": {
"type": "string",
"required": False,
"description": "Report postfix. Required when group id_mode is manual; auto-generated when omitted for auto groups",
"example": "run-abc123",
},
"body": {
"type": "string",
"required": True,
"description": "Full HTML document or HTML fragment to render on the public report URL",
"example": "<html><body><h1>Agent run complete</h1><p>All checks passed.</p></body></html>",
},
"access": {
"type": "string",
"required": False,
"description": "public or password",
"example": "public",
},
"password": {
"type": "string",
"required": False,
"description": "Required when access is password (unless group default password is set)",
"example": "secret-pass",
},
},
"responses": {
"200": {
"description": "Report created or updated",
"example": {
"success": True,
"report_group": "acme-agent-runs",
"report_id": "run-abc123",
"url": "https://urmiai.com/reports/acme-agent-runs/run-abc123",
"access": "public",
},
},
"401": {"description": "Missing or invalid API key"},
"404": {"description": "Report group not found"},
"422": {"description": "Validation error"},
},
"notes": [
"Create the report group in the Urmi console before calling this API.",
"Upsert semantics: same report_group + report_id updates the existing HTML body.",
"The hosted page renders only your HTML — no Urmi chrome or branding.",
],
}

View file

@ -23,6 +23,8 @@ import { UrmiOtpPage } from './features/agent/UrmiOtpPage'
import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage' import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage'
import { AgentTemplatesPage } from './features/agent/AgentTemplatesPage' import { AgentTemplatesPage } from './features/agent/AgentTemplatesPage'
import { AgentTemplateDocsPage } from './features/agent/AgentTemplateDocsPage' import { AgentTemplateDocsPage } from './features/agent/AgentTemplateDocsPage'
import { UrmiportPage } from './features/agent/UrmiportPage'
import { PublicReportPage } from './features/reports/PublicReportPage'
import { KycPage } from './features/compliance/KycPage' import { KycPage } from './features/compliance/KycPage'
import { DltPage } from './features/compliance/DltPage' import { DltPage } from './features/compliance/DltPage'
import { ComplianceGate } from './features/compliance/ComplianceProvider' import { ComplianceGate } from './features/compliance/ComplianceProvider'
@ -45,6 +47,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<LandingPage />} /> <Route path="/" element={<LandingPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} /> <Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/reports/:group/:reportId" element={<PublicReportPage />} />
<Route path="/login" element={<AuthPage initialMode="login" />} /> <Route path="/login" element={<AuthPage initialMode="login" />} />
<Route path="/register" element={<AuthPage initialMode="register" />} /> <Route path="/register" element={<AuthPage initialMode="register" />} />
<Route path="/admin/*" element={<AdminApp />} /> <Route path="/admin/*" element={<AdminApp />} />
@ -74,6 +77,7 @@ export default function App() {
<Route path="agent/urmiai/templates/:slug/docs" element={<AgentTemplateDocsPage />} /> <Route path="agent/urmiai/templates/:slug/docs" element={<AgentTemplateDocsPage />} />
<Route path="agent/urmiotp" element={<UrmiOtpPage />} /> <Route path="agent/urmiotp" element={<UrmiOtpPage />} />
<Route path="agent/urmiotp/projects/:projectId/docs" element={<UrmiOtpDocsPage />} /> <Route path="agent/urmiotp/projects/:projectId/docs" element={<UrmiOtpDocsPage />} />
<Route path="agent/urmiport" element={<UrmiportPage />} />
<Route path=":section/*" element={<Placeholder />} /> <Route path=":section/*" element={<Placeholder />} />
</Route> </Route>
</Route> </Route>

View file

@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { import {
Activity, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign, Activity, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign,
Code2, CreditCard, Cpu, FileCheck2, Gauge, KeyRound, LayoutDashboard, Code2, CreditCard, Cpu, FileCheck2, FileText, Gauge, KeyRound, LayoutDashboard,
LogOut, Menu, MessageSquareText, Network, Phone, Search, Settings, ShieldCheck, LogOut, Menu, MessageSquareText, Network, Phone, Search, Settings, ShieldCheck,
SlidersHorizontal, TestTube2, Users, Webhook, X, Zap, SlidersHorizontal, TestTube2, Users, Webhook, X, Zap,
MessageCircle, MessageCircle,
@ -26,6 +26,7 @@ const groups = [
{ label: 'UrmiAI', path: '/console/agent/urmiai', icon: Bot }, { label: 'UrmiAI', path: '/console/agent/urmiai', icon: Bot },
{ label: 'UrmIOT', path: '/console/agent/urmiot', icon: Cpu }, { label: 'UrmIOT', path: '/console/agent/urmiot', icon: Cpu },
{ label: 'UrmiOTP', path: '/console/agent/urmiotp', icon: KeyRound }, { label: 'UrmiOTP', path: '/console/agent/urmiotp', icon: KeyRound },
{ label: 'Urmiport', path: '/console/agent/urmiport', icon: FileText },
]}, ]},
{ label: 'Communicate', links: [ { label: 'Communicate', links: [
{ label: 'Messaging', path: '/console/messaging', icon: MessageSquareText }, { label: 'Messaging', path: '/console/messaging', icon: MessageSquareText },

View file

@ -14,6 +14,7 @@ function TestModal({
}) { }) {
const [to, setTo] = useState('') const [to, setTo] = useState('')
const [customerName, setCustomerName] = useState('') const [customerName, setCustomerName] = useState('')
const [reportGroup, setReportGroup] = useState('')
const [reportId, setReportId] = useState('') const [reportId, setReportId] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
@ -26,6 +27,7 @@ function TestModal({
await agentTemplatesApi.test(template.slug, { await agentTemplatesApi.test(template.slug, {
to: to.trim(), to: to.trim(),
customer_name: customerName.trim(), customer_name: customerName.trim(),
report_group: reportGroup.trim(),
report_id: reportId.trim() || undefined, report_id: reportId.trim() || undefined,
}) })
onSent() onSent()
@ -56,10 +58,15 @@ function TestModal({
<span>Customer name</span> <span>Customer name</span>
<input value={customerName} onChange={(e) => setCustomerName(e.target.value)} placeholder="Suyash" required /> <input value={customerName} onChange={(e) => setCustomerName(e.target.value)} placeholder="Suyash" required />
</label> </label>
<label>
<span>Report group</span>
<input value={reportGroup} onChange={(e) => setReportGroup(e.target.value)} placeholder="acme-agent-runs" required />
<small>Urmiport group slug used in urmiai.com/reports/{'{group}'}/{'{id}'}.</small>
</label>
<label> <label>
<span>Report ID (optional)</span> <span>Report ID (optional)</span>
<input value={reportId} onChange={(e) => setReportId(e.target.value)} placeholder="run-abc123" /> <input value={reportId} onChange={(e) => setReportId(e.target.value)} placeholder="run-abc123" />
<small>Used for the dynamic View Report button URL suffix.</small> <small>Report postfix; auto-generated when omitted.</small>
</label> </label>
{error && <p className="auth-error">{error}</p>} {error && <p className="auth-error">{error}</p>}
</div> </div>

View file

@ -0,0 +1,284 @@
import { ArrowRight, Code2, ExternalLink, FileText, Globe, Lock, Plus, X } from 'lucide-react'
import { useEffect, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { urmiportApi, type UrmiportApiDoc, type UrmiportGroup, type UrmiportReport } from '../../lib/api'
const GROUP_SLUG_RE = /^[a-z][a-z0-9-]{1,48}[a-z0-9]$/
function GroupModal({
onClose,
onCreated,
}: {
onClose: () => void
onCreated: (group: UrmiportGroup) => void
}) {
const [reportGroup, setReportGroup] = useState('')
const [displayName, setDisplayName] = useState('')
const [defaultAccess, setDefaultAccess] = useState<'public' | 'password'>('public')
const [password, setPassword] = useState('')
const [idMode, setIdMode] = useState<'auto' | 'manual'>('auto')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
async function submit(event: FormEvent) {
event.preventDefault()
setError('')
const slug = reportGroup.trim().toLowerCase()
if (!GROUP_SLUG_RE.test(slug) || slug.includes('--')) {
setError('Report group must be 3–50 chars: lowercase letters, numbers, hyphens')
return
}
if (defaultAccess === 'password' && !password.trim()) {
setError('Password is required for password-protected groups')
return
}
setSaving(true)
try {
const group = await urmiportApi.createGroup({
report_group: slug,
display_name: displayName.trim() || undefined,
default_access: defaultAccess,
password: defaultAccess === 'password' ? password.trim() : undefined,
id_mode: idMode,
})
onCreated(group)
onClose()
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Unable to create report group')
} finally {
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<form className="modal-card otp-project-modal" onSubmit={submit} onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h2>Create report group</h2>
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
</div>
<div className="otp-project-form">
<label>
<span>Report group</span>
<input
value={reportGroup}
onChange={(event) => setReportGroup(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder="acme-agent-runs"
required
/>
<small>Used in URLs: urmiai.com/reports/{'{report_group}'}/{'{report_id}'}</small>
</label>
<label>
<span>Display name (optional)</span>
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="Acme agent runs" />
</label>
<fieldset>
<legend>Default access</legend>
<label><input type="radio" checked={defaultAccess === 'public'} onChange={() => setDefaultAccess('public')} /> Public</label>
<label><input type="radio" checked={defaultAccess === 'password'} onChange={() => setDefaultAccess('password')} /> Password</label>
</fieldset>
{defaultAccess === 'password' && (
<label>
<span>Default password</span>
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} required />
</label>
)}
<fieldset>
<legend>Report ID mode</legend>
<label><input type="radio" checked={idMode === 'auto'} onChange={() => setIdMode('auto')} /> Auto-generate postfix</label>
<label><input type="radio" checked={idMode === 'manual'} onChange={() => setIdMode('manual')} /> Require report_id in API</label>
</fieldset>
{error && <p className="auth-error">{error}</p>}
</div>
<div className="modal-actions">
<button className="primary-button" disabled={saving}>{saving ? 'Creating…' : 'Create group'}</button>
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
)
}
export function UrmiportPage() {
const [groups, setGroups] = useState<UrmiportGroup[]>([])
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
const [reports, setReports] = useState<UrmiportReport[]>([])
const [docs, setDocs] = useState<UrmiportApiDoc | null>(null)
const [loading, setLoading] = useState(true)
const [reportsLoading, setReportsLoading] = useState(false)
const [error, setError] = useState('')
const [showModal, setShowModal] = useState(false)
useEffect(() => {
Promise.all([urmiportApi.listGroups(), urmiportApi.docs()])
.then(([groupList, apiDocs]) => {
setGroups(groupList)
setDocs(apiDocs)
if (groupList[0]) setSelectedGroup(groupList[0].report_group)
})
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load Urmiport'))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (!selectedGroup) {
setReports([])
return
}
setReportsLoading(true)
urmiportApi.listReports(selectedGroup)
.then(setReports)
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load reports'))
.finally(() => setReportsLoading(false))
}, [selectedGroup])
return (
<div className="urmiport-page">
<header className="page-header">
<div>
<span className="eyebrow">Agent</span>
<h1><FileText size={24} /> Urmiport</h1>
<p>Host HTML reports at urmiai.com/reports/{'{report_group}'}/{'{report_id}'} — no Urmi branding on the public page.</p>
</div>
<button className="primary-button" onClick={() => setShowModal(true)}>
<Plus size={16} /> New report group
</button>
</header>
{error && <p className="auth-error">{error}</p>}
<section className="panel urmiport-groups-panel">
<div className="panel-heading">
<div>
<h2>Report groups</h2>
<p>Each group is a namespace for hosted reports. Publish HTML via the API below.</p>
</div>
</div>
{loading ? (
<p className="panel-empty">Loading…</p>
) : !groups.length ? (
<p className="panel-empty">Create a report group to start hosting reports.</p>
) : (
<div className="urmiport-group-table">
<div className="urmiport-group-table-head">
<span>Group</span>
<span>Access</span>
<span>ID mode</span>
<span>Reports</span>
<span>URL pattern</span>
</div>
{groups.map((group) => (
<button
type="button"
key={group.id}
className={`urmiport-group-row ${selectedGroup === group.report_group ? 'is-selected' : ''}`}
onClick={() => setSelectedGroup(group.report_group)}
>
<div>
<strong>{group.display_name || group.report_group}</strong>
<small>{group.report_group}</small>
</div>
<span>{group.default_access === 'password' ? <><Lock size={13} /> Password</> : <><Globe size={13} /> Public</>}</span>
<span>{group.id_mode === 'auto' ? 'Auto' : 'Manual'}</span>
<span>{group.report_count}</span>
<code>{group.url_pattern}</code>
</button>
))}
</div>
)}
</section>
{selectedGroup && (
<section className="panel urmiport-reports-panel">
<div className="panel-heading">
<div>
<h2>Reports in {selectedGroup}</h2>
<p>Published via API. Same report_group + report_id updates the HTML body.</p>
</div>
</div>
{reportsLoading ? (
<p className="panel-empty">Loading reports…</p>
) : !reports.length ? (
<p className="panel-empty">No reports yet. Publish one using the API docs below.</p>
) : (
<div className="urmiport-report-table">
<div className="urmiport-report-table-head">
<span>Report ID</span>
<span>Access</span>
<span>Updated</span>
<span>URL</span>
</div>
{reports.map((report) => (
<div className="urmiport-report-row" key={report.id}>
<strong>{report.report_id}</strong>
<span>{report.access === 'password' ? 'Password' : 'Public'}</span>
<span>{new Date(report.updated_at).toLocaleString()}</span>
<a href={report.url} target="_blank" rel="noreferrer">
<ExternalLink size={13} /> Open
</a>
</div>
))}
</div>
)}
</section>
)}
{docs && (
<section className="panel urmiport-docs-panel">
<div className="panel-heading">
<div>
<h2><Code2 size={18} /> API documentation</h2>
<p>{docs.summary}</p>
</div>
<span className="explorer-method">{docs.method}</span>
</div>
<div className="urmiport-docs-grid">
<div>
<h3>Endpoint</h3>
<code>POST /api/v1/urmiport/reports</code>
<h3>Authentication</h3>
<p><code>{docs.authentication.header}: {docs.authentication.format}</code></p>
<h3>Request body</h3>
<ul className="agent-template-notes">
{Object.entries(docs.request_body).map(([key, field]) => (
<li key={key}>
<strong>{key}</strong>{field.required ? ' (required)' : ' (optional)'} — {field.description}
{field.example ? <> Example: <code>{field.example}</code></> : null}
</li>
))}
</ul>
<h3>Notes</h3>
<ul className="agent-template-notes">
{docs.notes?.map((note) => <li key={note}>{note}</li>)}
</ul>
</div>
<div>
<h3>Example request</h3>
<pre className="explorer-code">{JSON.stringify({
report_group: 'acme-agent-runs',
report_id: 'run-abc123',
body: '<html><body><h1>Agent run complete</h1></body></html>',
access: 'public',
}, null, 2)}</pre>
<h3>Example response</h3>
<pre className="explorer-code">{JSON.stringify(docs.responses['200']?.example ?? {}, null, 2)}</pre>
<Link to="/console/api-explorer" className="text-button">
Try in API Explorer <ArrowRight size={14} />
</Link>
</div>
</div>
</section>
)}
{showModal && (
<GroupModal
onClose={() => setShowModal(false)}
onCreated={(group) => {
setGroups((current) => [group, ...current])
setSelectedGroup(group.report_group)
}}
/>
)}
</div>
)
}

View file

@ -6,6 +6,7 @@ import {
integrationsApi, integrationsApi,
messagesApi, messagesApi,
numbersApi, numbersApi,
urmiportApi,
voiceApi, voiceApi,
type AgentMessageApiDoc, type AgentMessageApiDoc,
type AgentMessageSendResponse, type AgentMessageSendResponse,
@ -13,6 +14,8 @@ import {
type MessageApiDoc, type MessageApiDoc,
type PhoneNumber, type PhoneNumber,
type SendMessageResponse, type SendMessageResponse,
type UrmiportApiDoc,
type UrmiportPublishResponse,
type VoiceApiDocs, type VoiceApiDocs,
type VoiceCall, type VoiceCall,
} from '../../lib/api' } from '../../lib/api'
@ -137,34 +140,51 @@ export function ApiExplorerPage() {
const [agentTemplate, setAgentTemplate] = useState('') const [agentTemplate, setAgentTemplate] = useState('')
const [agentTo, setAgentTo] = useState('') const [agentTo, setAgentTo] = useState('')
const [agentCustomerName, setAgentCustomerName] = useState('') const [agentCustomerName, setAgentCustomerName] = useState('')
const [agentReportGroup, setAgentReportGroup] = useState('')
const [agentReportId, setAgentReportId] = useState('') const [agentReportId, setAgentReportId] = useState('')
const [agentResponse, setAgentResponse] = useState<AgentMessageSendResponse | null>(null) const [agentResponse, setAgentResponse] = useState<AgentMessageSendResponse | null>(null)
const [agentError, setAgentError] = useState('') const [agentError, setAgentError] = useState('')
const [agentRunning, setAgentRunning] = useState(false) const [agentRunning, setAgentRunning] = useState(false)
const [urmiportDoc, setUrmiportDoc] = useState<UrmiportApiDoc | null>(null)
const [urmiportGroup, setUrmiportGroup] = useState('')
const [urmiportReportId, setUrmiportReportId] = useState('')
const [urmiportBody, setUrmiportBody] = useState('<html><body><h1>Agent run complete</h1><p>All checks passed.</p></body></html>')
const [urmiportAccess, setUrmiportAccess] = useState('public')
const [urmiportPassword, setUrmiportPassword] = useState('')
const [urmiportResponse, setUrmiportResponse] = useState<UrmiportPublishResponse | null>(null)
const [urmiportError, setUrmiportError] = useState('')
const [urmiportRunning, setUrmiportRunning] = useState(false)
const [fromDid, setFromDid] = useState('') const [fromDid, setFromDid] = useState('')
const [callTo, setCallTo] = useState('') const [callTo, setCallTo] = useState('')
const [voiceResponse, setVoiceResponse] = useState<VoiceCall | null>(null) const [voiceResponse, setVoiceResponse] = useState<VoiceCall | null>(null)
const [voiceError, setVoiceError] = useState('') const [voiceError, setVoiceError] = useState('')
const [voiceRunning, setVoiceRunning] = useState(false) const [voiceRunning, setVoiceRunning] = useState(false)
const [copied, setCopied] = useState<'sms' | 'voice' | 'agent' | null>(null) const [copied, setCopied] = useState<'sms' | 'voice' | 'agent' | 'urmiport' | null>(null)
const [loadError, setLoadError] = useState('') const [loadError, setLoadError] = useState('')
useEffect(() => { useEffect(() => {
Promise.allSettled([ Promise.allSettled([
messagesApi.docs(), messagesApi.docs(),
agentMessagesApi.docs(), agentMessagesApi.docs(),
urmiportApi.docs(),
voiceApi.docs(), voiceApi.docs(),
integrationsApi.docs(), integrationsApi.docs(),
numbersApi.mine(), numbersApi.mine(),
]) ])
.then((results) => { .then((results) => {
const [smsResult, agentResult, voiceResult, integrationResult, numbersResult] = results const [smsResult, agentResult, urmiportResult, voiceResult, integrationResult, numbersResult] = results
if (smsResult.status === 'fulfilled') setSmsDoc(smsResult.value) if (smsResult.status === 'fulfilled') setSmsDoc(smsResult.value)
if (agentResult.status === 'fulfilled') { if (agentResult.status === 'fulfilled') {
setAgentDoc(agentResult.value) setAgentDoc(agentResult.value)
setAgentTemplate(agentResult.value.request_body.template?.example ?? 'agent_run_completion') setAgentTemplate(agentResult.value.request_body.template?.example ?? 'agent_run_completion')
setAgentReportGroup(agentResult.value.request_body.report_group?.example ?? 'acme-agent-runs')
}
if (urmiportResult.status === 'fulfilled') {
setUrmiportDoc(urmiportResult.value)
setUrmiportGroup(urmiportResult.value.request_body.report_group?.example ?? 'acme-agent-runs')
} }
if (voiceResult.status === 'fulfilled') setVoiceDoc(normalizeVoiceDocs(voiceResult.value)) if (voiceResult.status === 'fulfilled') setVoiceDoc(normalizeVoiceDocs(voiceResult.value))
if (integrationResult.status === 'fulfilled') { if (integrationResult.status === 'fulfilled') {
@ -206,7 +226,8 @@ export function ApiExplorerPage() {
template: agentTemplate || agentDoc.request_body.template.example, template: agentTemplate || agentDoc.request_body.template.example,
to: agentTo || agentDoc.request_body.to.example, to: agentTo || agentDoc.request_body.to.example,
customer_name: agentCustomerName || agentDoc.request_body.customer_name.example, customer_name: agentCustomerName || agentDoc.request_body.customer_name.example,
report_id: agentReportId || agentDoc.request_body.report_id.example, report_group: agentReportGroup || agentDoc.request_body.report_group?.example || 'acme-agent-runs',
report_id: agentReportId || agentDoc.request_body.report_id?.example,
}, null, 2) }, null, 2)
return [ return [
`curl -X POST '${API_BASE_URL}${agentDoc.path}' \\`, `curl -X POST '${API_BASE_URL}${agentDoc.path}' \\`,
@ -214,7 +235,24 @@ export function ApiExplorerPage() {
` -H 'Content-Type: application/json' \\`, ` -H 'Content-Type: application/json' \\`,
` -d '${body.replace(/'/g, `'\\''`)}'`, ` -d '${body.replace(/'/g, `'\\''`)}'`,
].join('\n') ].join('\n')
}, [agentCustomerName, agentDoc, agentReportId, agentTemplate, agentTo, apiKey]) }, [agentCustomerName, agentDoc, agentReportGroup, agentReportId, agentTemplate, agentTo, apiKey])
const urmiportCurl = useMemo(() => {
if (!urmiportDoc) return ''
const body = JSON.stringify({
report_group: urmiportGroup || urmiportDoc.request_body.report_group.example,
report_id: urmiportReportId || urmiportDoc.request_body.report_id?.example,
body: urmiportBody,
access: urmiportAccess,
password: urmiportAccess === 'password' ? urmiportPassword || urmiportDoc.request_body.password?.example : undefined,
}, null, 2)
return [
`curl -X POST '${API_BASE_URL}${urmiportDoc.path}' \\`,
` -H 'Authorization: Bearer ${apiKey || 'urmi_live_<your_api_key>'}' \\`,
` -H 'Content-Type: application/json' \\`,
` -d '${body.replace(/'/g, `'\\''`)}'`,
].join('\n')
}, [apiKey, urmiportAccess, urmiportBody, urmiportDoc, urmiportGroup, urmiportPassword, urmiportReportId])
const voiceCurl = useMemo(() => { const voiceCurl = useMemo(() => {
if (!voiceDoc) return '' if (!voiceDoc) return ''
@ -259,6 +297,7 @@ export function ApiExplorerPage() {
template: agentTemplate.trim(), template: agentTemplate.trim(),
to: agentTo.trim(), to: agentTo.trim(),
customer_name: agentCustomerName.trim(), customer_name: agentCustomerName.trim(),
report_group: agentReportGroup.trim(),
report_id: agentReportId.trim() || undefined, report_id: agentReportId.trim() || undefined,
}) })
setAgentResponse(result) setAgentResponse(result)
@ -287,8 +326,28 @@ export function ApiExplorerPage() {
} }
} }
async function copyCurl(kind: 'sms' | 'voice' | 'agent') { async function runUrmiport() {
const text = kind === 'sms' ? smsCurl : kind === 'agent' ? agentCurl : voiceCurl setUrmiportError('')
setUrmiportResponse(null)
setUrmiportRunning(true)
try {
const result = await urmiportApi.publish(apiKey.trim(), {
report_group: urmiportGroup.trim(),
report_id: urmiportReportId.trim() || undefined,
body: urmiportBody,
access: urmiportAccess as 'public' | 'password',
password: urmiportAccess === 'password' ? urmiportPassword.trim() || undefined : undefined,
})
setUrmiportResponse(result)
} catch (reason) {
setUrmiportError(reason instanceof Error ? reason.message : 'Request failed')
} finally {
setUrmiportRunning(false)
}
}
async function copyCurl(kind: 'sms' | 'voice' | 'agent' | 'urmiport') {
const text = kind === 'sms' ? smsCurl : kind === 'agent' ? agentCurl : kind === 'urmiport' ? urmiportCurl : voiceCurl
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
setCopied(kind) setCopied(kind)
window.setTimeout(() => setCopied(null), 2000) window.setTimeout(() => setCopied(null), 2000)
@ -425,12 +484,14 @@ export function ApiExplorerPage() {
key === 'template' ? agentTemplate key === 'template' ? agentTemplate
: key === 'to' ? agentTo : key === 'to' ? agentTo
: key === 'customer_name' ? agentCustomerName : key === 'customer_name' ? agentCustomerName
: agentReportId : key === 'report_group' ? agentReportGroup
: agentReportId
} }
onChange={(e) => { onChange={(e) => {
if (key === 'template') setAgentTemplate(e.target.value) if (key === 'template') setAgentTemplate(e.target.value)
if (key === 'to') setAgentTo(e.target.value) if (key === 'to') setAgentTo(e.target.value)
if (key === 'customer_name') setAgentCustomerName(e.target.value) if (key === 'customer_name') setAgentCustomerName(e.target.value)
if (key === 'report_group') setAgentReportGroup(e.target.value)
if (key === 'report_id') setAgentReportId(e.target.value) if (key === 'report_id') setAgentReportId(e.target.value)
}} }}
placeholder={field.example} placeholder={field.example}
@ -475,6 +536,100 @@ export function ApiExplorerPage() {
</ExplorerGroup> </ExplorerGroup>
)} )}
{urmiportDoc && (
<ExplorerGroup title="Urmiport">
<section className="panel explorer-endpoint-card">
<div className="panel-heading">
<div>
<h3>{urmiportDoc.title}</h3>
<p>{urmiportDoc.summary}</p>
</div>
<span className="explorer-method">{urmiportDoc.method}</span>
</div>
<FieldRow label="Endpoint" value={`${API_BASE_URL}${urmiportDoc.path}`} />
<FieldRow label="Auth" value={`${urmiportDoc.authentication.header}: ${urmiportDoc.authentication.format}`} />
<div className="explorer-section">
<h4>Request body</h4>
<div className="explorer-request-grid">
<div className="explorer-input">
<label>
<strong>report_group</strong>
<span>{urmiportDoc.request_body.report_group.description}</span>
<input value={urmiportGroup} onChange={(e) => setUrmiportGroup(e.target.value)} placeholder={urmiportDoc.request_body.report_group.example} />
</label>
</div>
<div className="explorer-input">
<label>
<strong>report_id</strong>
<span>{urmiportDoc.request_body.report_id.description}</span>
<input value={urmiportReportId} onChange={(e) => setUrmiportReportId(e.target.value)} placeholder={urmiportDoc.request_body.report_id.example} />
</label>
</div>
<div className="explorer-input explorer-input-wide">
<label>
<strong>body</strong>
<span>{urmiportDoc.request_body.body.description}</span>
<textarea value={urmiportBody} onChange={(e) => setUrmiportBody(e.target.value)} rows={6} />
</label>
</div>
<div className="explorer-input">
<label>
<strong>access</strong>
<span>{urmiportDoc.request_body.access.description}</span>
<select value={urmiportAccess} onChange={(e) => setUrmiportAccess(e.target.value)}>
<option value="public">public</option>
<option value="password">password</option>
</select>
</label>
</div>
{urmiportAccess === 'password' && (
<div className="explorer-input">
<label>
<strong>password</strong>
<span>{urmiportDoc.request_body.password.description}</span>
<input type="password" value={urmiportPassword} onChange={(e) => setUrmiportPassword(e.target.value)} placeholder={urmiportDoc.request_body.password.example} />
</label>
</div>
)}
</div>
</div>
<div className="explorer-section">
<h4>API key</h4>
<label className="explorer-input">
<strong>Bearer token</strong>
<span>Create the report group in Agent → Urmiport before publishing.</span>
<input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="urmi_live_..." />
</label>
</div>
<div className="explorer-actions">
<button className="primary-button" onClick={runUrmiport} disabled={urmiportRunning || !apiKey.trim()}>
<Play size={15} /> {urmiportRunning ? 'Publishing…' : 'Try request'}
</button>
<button className="secondary-button" onClick={() => copyCurl('urmiport')}>
<Copy size={15} /> {copied === 'urmiport' ? 'Copied cURL' : 'Copy cURL'}
</button>
</div>
{urmiportError && <p className="auth-error">{urmiportError}</p>}
{urmiportResponse && (
<div className="explorer-response success">
<strong><CheckCircle2 size={15} /> 200 OK</strong>
<pre>{JSON.stringify(urmiportResponse, null, 2)}</pre>
</div>
)}
<div className="explorer-section">
<h4>Example cURL</h4>
<pre className="explorer-code">{urmiportCurl}</pre>
</div>
</section>
</ExplorerGroup>
)}
{voiceDoc && integrationDocs && ( {voiceDoc && integrationDocs && (
<ExplorerGroup title="Numbers"> <ExplorerGroup title="Numbers">
<RelayCallout principle={voiceDoc.relay.principle} latency={voiceDoc.relay.latency} /> <RelayCallout principle={voiceDoc.relay.principle} latency={voiceDoc.relay.latency} />

View file

@ -0,0 +1,80 @@
import { useEffect, useState, type FormEvent } from 'react'
import { useParams } from 'react-router-dom'
import { urmiportApi } from '../../lib/api'
export function PublicReportPage() {
const { group, reportId } = useParams<{ group: string; reportId: string }>()
const [loading, setLoading] = useState(true)
const [requiresPassword, setRequiresPassword] = useState(false)
const [html, setHtml] = useState<string | null>(null)
const [password, setPassword] = useState('')
const [error, setError] = useState('')
useEffect(() => {
if (!group || !reportId) return
setLoading(true)
setError('')
urmiportApi.viewReport(group, reportId)
.then((payload) => {
setRequiresPassword(payload.requires_password)
setHtml(payload.html)
})
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Report not found'))
.finally(() => setLoading(false))
}, [group, reportId])
async function unlock(event: FormEvent) {
event.preventDefault()
if (!group || !reportId) return
setError('')
try {
const payload = await urmiportApi.unlockReport(group, reportId, password)
setHtml(payload.html)
setRequiresPassword(false)
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Invalid password')
}
}
if (loading) {
return <div className="public-report-loading">Loading report…</div>
}
if (error && !requiresPassword) {
return <div className="public-report-error">{error}</div>
}
if (requiresPassword && !html) {
return (
<div className="public-report-gate">
<form onSubmit={unlock}>
<h1>Password required</h1>
<p>This report is protected. Enter the password to continue.</p>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Password"
autoFocus
required
/>
{error && <p className="public-report-error-text">{error}</p>}
<button type="submit">View report</button>
</form>
</div>
)
}
if (!html) {
return <div className="public-report-error">Report not found</div>
}
return (
<iframe
className="public-report-frame"
title="Report"
srcDoc={html}
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
/>
)
}

View file

@ -1277,6 +1277,112 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.otp-project-form fieldset { display: flex; gap: 20px; padding: 0; border: 0; } .otp-project-form fieldset { display: flex; gap: 20px; padding: 0; border: 0; }
.otp-project-form fieldset legend { margin-bottom: 8px; } .otp-project-form fieldset legend { margin-bottom: 8px; }
.otp-project-form fieldset label { display: flex; align-items: center; gap: 7px; color: #607089; font-size: 10px; } .otp-project-form fieldset label { display: flex; align-items: center; gap: 7px; color: #607089; font-size: 10px; }
.urmiport-page { max-width: 1180px; }
.urmiport-groups-panel, .urmiport-reports-panel, .urmiport-docs-panel { margin-bottom: 20px; }
.urmiport-group-table, .urmiport-report-table { margin-top: 18px; }
.urmiport-group-table-head,
.urmiport-group-row {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) minmax(100px, .7fr) minmax(90px, .6fr) minmax(70px, .5fr) minmax(220px, 1.4fr);
gap: 14px;
align-items: center;
text-align: left;
}
.urmiport-report-table-head,
.urmiport-report-row {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(100px, .7fr) minmax(160px, .9fr) auto;
gap: 14px;
align-items: center;
}
.urmiport-group-table-head, .urmiport-report-table-head {
padding: 0 10px 9px;
color: #8a95a7;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
}
.urmiport-group-row {
width: 100%;
padding: 14px 10px;
border: 0;
border-top: 1px solid #edf0f4;
background: transparent;
cursor: pointer;
}
.urmiport-group-row.is-selected { background: #f5f9ff; }
.urmiport-group-row strong, .urmiport-group-row small, .urmiport-report-row strong { display: block; }
.urmiport-group-row strong, .urmiport-report-row strong { color: #35435a; font-size: 12px; }
.urmiport-group-row small { margin-top: 4px; color: #8a95a7; font-size: 9px; }
.urmiport-group-row span, .urmiport-report-row span { display: inline-flex; align-items: center; gap: 5px; color: #607089; font-size: 10px; }
.urmiport-group-row code { color: #1475ff; font-size: 9px; word-break: break-all; }
.urmiport-report-row { padding: 14px 10px; border-top: 1px solid #edf0f4; }
.urmiport-report-row a { display: inline-flex; align-items: center; gap: 5px; color: #1475ff; font-size: 10px; }
.urmiport-docs-grid { display: grid; grid-template-columns: 1.1fr 1fr; gap: 24px; margin-top: 18px; }
.urmiport-docs-grid h3 { margin: 18px 0 8px; color: #35435a; font-size: 11px; }
.urmiport-docs-grid h3:first-child { margin-top: 0; }
.explorer-input-wide { grid-column: 1 / -1; }
.explorer-input textarea {
width: 100%;
min-height: 120px;
padding: 10px 12px;
border: 1px solid #dfe5ed;
border-radius: 5px;
color: #26364d;
font-size: 12px;
font-family: inherit;
resize: vertical;
}
.public-report-frame {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
.public-report-loading,
.public-report-error {
min-height: 100vh;
display: grid;
place-items: center;
color: #46546a;
font-size: 14px;
}
.public-report-gate {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: #f7f9fc;
}
.public-report-gate form {
width: min(100%, 360px);
display: grid;
gap: 12px;
padding: 24px;
border: 1px solid #dfe5ed;
border-radius: 8px;
background: #fff;
}
.public-report-gate h1 { margin: 0; font-size: 18px; color: #26364d; }
.public-report-gate p { margin: 0; color: #607089; font-size: 12px; line-height: 1.5; }
.public-report-gate input {
width: 100%;
padding: 10px 12px;
border: 1px solid #dfe5ed;
border-radius: 5px;
}
.public-report-gate button {
padding: 10px 14px;
border: 0;
border-radius: 5px;
background: #1475ff;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.public-report-error-text { margin: 0; color: #d14343; font-size: 11px; }
.otp-terms { grid-template-columns: auto 1fr; align-items: start; } .otp-terms { grid-template-columns: auto 1fr; align-items: start; }
.otp-terms input { margin-top: 2px; accent-color: #1475ff; } .otp-terms input { margin-top: 2px; accent-color: #1475ff; }
.otp-terms span { line-height: 1.45; } .otp-terms span { line-height: 1.45; }
@ -1345,6 +1451,9 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.agent-template-actions { flex-wrap: wrap; } .agent-template-actions { flex-wrap: wrap; }
.otp-project-table-head { display: none; } .otp-project-table-head { display: none; }
.otp-project-row { grid-template-columns: 1fr; align-items: start; } .otp-project-row { grid-template-columns: 1fr; align-items: start; }
.urmiport-group-table-head, .urmiport-report-table-head { display: none; }
.urmiport-group-row, .urmiport-report-row { grid-template-columns: 1fr; align-items: start; }
.urmiport-docs-grid { grid-template-columns: 1fr; }
.templates-table-head { display: none; } .templates-table-head { display: none; }
.templates-table-row { grid-template-columns: 1fr; align-items: start; gap: 8px; } .templates-table-row { grid-template-columns: 1fr; align-items: start; gap: 8px; }
.templates-send-button { justify-self: start; } .templates-send-button { justify-self: start; }

View file

@ -493,6 +493,7 @@ export type AgentMessageSendResponse = {
template: string template: string
to: string to: string
customer_name: string customer_name: string
report_group: string
report_id: string report_id: string
report_url: string | null report_url: string | null
wa_message_id: string | null wa_message_id: string | null
@ -511,7 +512,7 @@ export const agentTemplatesApi = {
jsonRequest<{ template: string; allowed: boolean; retry_after?: string | null; last_tested_at?: string | null }>( jsonRequest<{ template: string; allowed: boolean; retry_after?: string | null; last_tested_at?: string | null }>(
`/api/v1/agent-templates/${slug}/test-status`, `/api/v1/agent-templates/${slug}/test-status`,
), ),
test: (slug: string, payload: { to: string; customer_name: string; report_id?: string }) => test: (slug: string, payload: { to: string; customer_name: string; report_group: string; report_id?: string }) =>
jsonRequest<AgentMessageSendResponse>(`/api/v1/agent-templates/${slug}/test`, { jsonRequest<AgentMessageSendResponse>(`/api/v1/agent-templates/${slug}/test`, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
@ -522,7 +523,7 @@ export const agentMessagesApi = {
docs: () => jsonRequest<AgentMessageApiDoc>('/api/v1/agent/messages/docs/send'), docs: () => jsonRequest<AgentMessageApiDoc>('/api/v1/agent/messages/docs/send'),
send: ( send: (
apiKey: string, apiKey: string,
payload: { template: string; to: string; customer_name: string; report_id?: string }, payload: { template: string; to: string; customer_name: string; report_group: string; report_id?: string },
) => ) =>
fetch(`${API_BASE_URL}/api/v1/agent/messages/send`, { fetch(`${API_BASE_URL}/api/v1/agent/messages/send`, {
method: 'POST', method: 'POST',
@ -540,6 +541,119 @@ export const agentMessagesApi = {
}), }),
} }
export type UrmiportGroup = {
id: string
report_group: string
display_name: string | null
default_access: 'public' | 'password'
password_protected: boolean
id_mode: 'auto' | 'manual'
report_count: number
url_pattern: string
created_at: string
}
export type UrmiportReport = {
id: string
report_group: string
report_id: string
access: 'public' | 'password'
password_protected: boolean
url: string
updated_at: string
created_at: string
}
export type UrmiportApiDoc = {
id: string
method: string
path: string
title: string
summary: string
authentication: {
type: string
header: string
format: string
alternate_header?: string
}
request_body: Record<string, {
type: string
required: boolean
description: string
example?: string
}>
responses: Record<string, { description: string; example?: Record<string, unknown> }>
notes?: string[]
}
export type UrmiportPublishResponse = UrmiportReport & { success: boolean }
export const urmiportApi = {
docs: () => jsonRequest<UrmiportApiDoc>('/api/v1/urmiport/docs'),
listGroups: () => jsonRequest<UrmiportGroup[]>('/api/v1/urmiport/groups'),
createGroup: (payload: {
report_group: string
display_name?: string
default_access: 'public' | 'password'
password?: string
id_mode: 'auto' | 'manual'
}) => jsonRequest<UrmiportGroup>('/api/v1/urmiport/groups', {
method: 'POST',
body: JSON.stringify(payload),
}),
listReports: (slug: string) => jsonRequest<UrmiportReport[]>(`/api/v1/urmiport/groups/${slug}/reports`),
viewReport: (group: string, reportId: string) =>
fetch(`${API_BASE_URL}/api/v1/urmiport/view/${group}/${reportId}`).then(async (response) => {
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(typeof body?.detail === 'string' ? body.detail : 'Report not found')
}
return response.json() as Promise<{
report_group: string
report_id: string
access: string
requires_password: boolean
html: string | null
}>
}),
unlockReport: (group: string, reportId: string, password: string) =>
fetch(`${API_BASE_URL}/api/v1/urmiport/view/${group}/${reportId}/unlock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
}).then(async (response) => {
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(typeof body?.detail === 'string' ? body.detail : 'Unable to unlock report')
}
return response.json() as Promise<{ html: string }>
}),
publish: (
apiKey: string,
payload: {
report_group: string
report_id?: string
body: string
access?: 'public' | 'password'
password?: string
},
) =>
fetch(`${API_BASE_URL}/api/v1/urmiport/reports`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}).then(async (response) => {
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(typeof body?.detail === 'string' ? body.detail : 'Request failed')
}
return response.json() as Promise<UrmiportPublishResponse>
}),
}
export const urmiOtpApi = { export const urmiOtpApi = {
listProjects: () => jsonRequest<UrmiOtpProject[]>('/api/v1/urmiotp/projects'), listProjects: () => jsonRequest<UrmiOtpProject[]>('/api/v1/urmiotp/projects'),
createProject: (payload: { createProject: (payload: {