reports
This commit is contained in:
parent
d272ae0bd3
commit
ff01efe4bb
17 changed files with 1378 additions and 21 deletions
|
|
@ -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.",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
180
backend/app/routes_urmiport.py
Normal file
180
backend/app/routes_urmiport.py
Normal 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}
|
||||||
23
backend/app/routes_urmiport_api.py
Normal file
23
backend/app/routes_urmiport_api.py
Normal 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)
|
||||||
300
backend/app/urmiport_service.py
Normal file
300
backend/app/urmiport_service.py
Normal 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.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
284
frontend/src/features/agent/UrmiportPage.tsx
Normal file
284
frontend/src/features/agent/UrmiportPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
: key === 'report_group' ? agentReportGroup
|
||||||
: agentReportId
|
: 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} />
|
||||||
|
|
|
||||||
80
frontend/src/features/reports/PublicReportPage.tsx
Normal file
80
frontend/src/features/reports/PublicReportPage.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue