diff --git a/backend/app/agent_message_service.py b/backend/app/agent_message_service.py index a77073a..287f804 100644 --- a/backend/app/agent_message_service.py +++ b/backend/app/agent_message_service.py @@ -22,6 +22,7 @@ class SendAgentMessagePayload(BaseModel): template: str = Field(min_length=2, max_length=64) to: str = Field(min_length=10, max_length=20) 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) @@ -72,6 +73,18 @@ def require_subscription(db: Session, user: UserAccount, template: AgentMessageT 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: cleaned = report_id.strip() if not cleaned or len(cleaned) > 120: @@ -84,7 +97,12 @@ def _validate_report_id(report_id: str) -> str: 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] = [ { "type": "body", @@ -103,7 +121,7 @@ def _build_template_components(template: AgentMessageTemplate, customer_name: st "type": "button", "sub_type": "url", "index": "0", - "parameters": [{"type": "text", "text": report_id}], + "parameters": [{"type": "text", "text": f"{report_group}/{report_id}"}], } ) return components @@ -128,8 +146,9 @@ async def send_agent_template_message( except ValueError as 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)}") - 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) try: @@ -149,6 +168,7 @@ async def send_agent_template_message( recipient=recipient, parameters={ "customer_name": payload.customer_name.strip(), + "report_group": report_group, "report_id": report_id, }, wa_message_id=wa_message_id, @@ -159,12 +179,17 @@ async def send_agent_template_message( db.commit() 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 { "success": True, "template": template.slug, "to": recipient, "customer_name": payload.customer_name.strip(), + "report_group": report_group, "report_id": report_id, "report_url": report_url, "wa_message_id": wa_message_id, @@ -201,6 +226,7 @@ async def run_console_template_test( *, to: str, customer_name: str, + report_group: str, report_id: str | None = None, ) -> dict: require_subscription(db, user, template) @@ -215,6 +241,7 @@ async def run_console_template_test( template=template.slug, to=to, customer_name=customer_name, + report_group=report_group, report_id=report_id, ) 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}`.", "to": "Recipient WhatsApp number with country code, e.g. 919149154337", "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": { "template": template.slug, "to": "919149154337", "customer_name": "Suyash", + "report_group": "acme-agent-runs", "report_id": "run-abc123", }, "response_example": { @@ -263,8 +292,9 @@ def build_agent_message_docs(template: AgentMessageTemplate) -> dict: "template": template.slug, "to": "919149154337", "customer_name": "Suyash", + "report_group": "acme-agent-runs", "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...", "dispatch_id": "00000000-0000-0000-0000-000000000001", }, @@ -272,6 +302,7 @@ def build_agent_message_docs(template: AgentMessageTemplate) -> dict: "notes": [ "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.", - "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.", ], } diff --git a/backend/app/agent_templates_seed.py b/backend/app/agent_templates_seed.py index 6b3cf67..2498f54 100644 --- a/backend/app/agent_templates_seed.py +++ b/backend/app/agent_templates_seed.py @@ -28,11 +28,18 @@ DEFAULT_AGENT_TEMPLATES = [ "description": "Recipient display name shown in the greeting", "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", "type": "string", "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", }, ], diff --git a/backend/app/main.py b/backend/app/main.py index 6cc2254..5fca9f0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -28,6 +28,8 @@ from .routes_voice import router as voice_router from .routes_whatsapp import router as whatsapp_router from .routes_agent_templates import router as agent_templates_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 @@ -95,6 +97,8 @@ app.include_router(voice_router) app.include_router(whatsapp_router) app.include_router(agent_templates_router) app.include_router(agent_messages_router) +app.include_router(urmiport_router) +app.include_router(urmiport_api_router) app.include_router(admin_router) diff --git a/backend/app/models.py b/backend/app/models.py index eb41407..cef5e4a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -109,6 +109,12 @@ class UserAccount(Base): agent_message_dispatches: Mapped[list["AgentMessageDispatchLog"]] = relationship( 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): @@ -609,6 +615,49 @@ class AgentMessageDispatchLog(Base): 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): """Runtime overrides for platform credentials (admin-editable).""" diff --git a/backend/app/routes_agent_messages.py b/backend/app/routes_agent_messages.py index c9cbc31..c433b8d 100644 --- a/backend/app/routes_agent_messages.py +++ b/backend/app/routes_agent_messages.py @@ -45,10 +45,16 @@ def _explorer_doc_for_template(template: AgentMessageTemplate) -> dict: "description": "Recipient name shown in the template body", "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": { "type": "string", "required": False, - "description": "Report id suffix for the dynamic View Report button URL", + "description": "Report postfix; auto-generated when omitted", "example": "run-abc123", }, }, @@ -60,8 +66,9 @@ def _explorer_doc_for_template(template: AgentMessageTemplate) -> dict: "template": template.slug, "to": "919149154337", "customer_name": "Suyash", + "report_group": "acme-agent-runs", "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...", "dispatch_id": "00000000-0000-0000-0000-000000000001", }, diff --git a/backend/app/routes_agent_templates.py b/backend/app/routes_agent_templates.py index b93b74f..e5a83e0 100644 --- a/backend/app/routes_agent_templates.py +++ b/backend/app/routes_agent_templates.py @@ -20,6 +20,7 @@ router = APIRouter(prefix="/api/v1/agent-templates", tags=["agent-templates"]) class AgentTemplateTestRequest(BaseModel): to: str = Field(min_length=10, max_length=20) 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) @@ -140,5 +141,6 @@ async def test_agent_template( template, to=payload.to, customer_name=payload.customer_name, + report_group=payload.report_group, report_id=payload.report_id, ) diff --git a/backend/app/routes_urmiport.py b/backend/app/routes_urmiport.py new file mode 100644 index 0000000..7afb91e --- /dev/null +++ b/backend/app/routes_urmiport.py @@ -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} diff --git a/backend/app/routes_urmiport_api.py b/backend/app/routes_urmiport_api.py new file mode 100644 index 0000000..16207a6 --- /dev/null +++ b/backend/app/routes_urmiport_api.py @@ -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) diff --git a/backend/app/urmiport_service.py b/backend/app/urmiport_service.py new file mode 100644 index 0000000..295c69f --- /dev/null +++ b/backend/app/urmiport_service.py @@ -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_", + "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": "

Agent run complete

All checks passed.

", + }, + "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.", + ], + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 450aa5c..b9d600b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,8 @@ import { UrmiOtpPage } from './features/agent/UrmiOtpPage' import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage' import { AgentTemplatesPage } from './features/agent/AgentTemplatesPage' 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 { DltPage } from './features/compliance/DltPage' import { ComplianceGate } from './features/compliance/ComplianceProvider' @@ -45,6 +47,7 @@ export default function App() { } /> } /> + } /> } /> } /> } /> @@ -74,6 +77,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 8319dcc..4819d49 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { 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, SlidersHorizontal, TestTube2, Users, Webhook, X, Zap, MessageCircle, @@ -26,6 +26,7 @@ const groups = [ { label: 'UrmiAI', path: '/console/agent/urmiai', icon: Bot }, { label: 'UrmIOT', path: '/console/agent/urmiot', icon: Cpu }, { label: 'UrmiOTP', path: '/console/agent/urmiotp', icon: KeyRound }, + { label: 'Urmiport', path: '/console/agent/urmiport', icon: FileText }, ]}, { label: 'Communicate', links: [ { label: 'Messaging', path: '/console/messaging', icon: MessageSquareText }, diff --git a/frontend/src/features/agent/AgentTemplatesPage.tsx b/frontend/src/features/agent/AgentTemplatesPage.tsx index acf3c48..b198207 100644 --- a/frontend/src/features/agent/AgentTemplatesPage.tsx +++ b/frontend/src/features/agent/AgentTemplatesPage.tsx @@ -14,6 +14,7 @@ function TestModal({ }) { const [to, setTo] = useState('') const [customerName, setCustomerName] = useState('') + const [reportGroup, setReportGroup] = useState('') const [reportId, setReportId] = useState('') const [error, setError] = useState('') const [sending, setSending] = useState(false) @@ -26,6 +27,7 @@ function TestModal({ await agentTemplatesApi.test(template.slug, { to: to.trim(), customer_name: customerName.trim(), + report_group: reportGroup.trim(), report_id: reportId.trim() || undefined, }) onSent() @@ -56,10 +58,15 @@ function TestModal({ Customer name setCustomerName(e.target.value)} placeholder="Suyash" required /> + {error &&

{error}

} diff --git a/frontend/src/features/agent/UrmiportPage.tsx b/frontend/src/features/agent/UrmiportPage.tsx new file mode 100644 index 0000000..4e5fd77 --- /dev/null +++ b/frontend/src/features/agent/UrmiportPage.tsx @@ -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 ( +
+
event.stopPropagation()}> +
+

Create report group

+ +
+
+ + +
+ Default access + + +
+ {defaultAccess === 'password' && ( + + )} +
+ Report ID mode + + +
+ {error &&

{error}

} +
+
+ + +
+
+
+ ) +} + +export function UrmiportPage() { + const [groups, setGroups] = useState([]) + const [selectedGroup, setSelectedGroup] = useState(null) + const [reports, setReports] = useState([]) + const [docs, setDocs] = useState(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 ( +
+
+
+ Agent +

Urmiport

+

Host HTML reports at urmiai.com/reports/{'{report_group}'}/{'{report_id}'} — no Urmi branding on the public page.

+
+ +
+ + {error &&

{error}

} + +
+
+
+

Report groups

+

Each group is a namespace for hosted reports. Publish HTML via the API below.

+
+
+ {loading ? ( +

Loading…

+ ) : !groups.length ? ( +

Create a report group to start hosting reports.

+ ) : ( +
+
+ Group + Access + ID mode + Reports + URL pattern +
+ {groups.map((group) => ( + + ))} +
+ )} +
+ + {selectedGroup && ( +
+
+
+

Reports in {selectedGroup}

+

Published via API. Same report_group + report_id updates the HTML body.

+
+
+ {reportsLoading ? ( +

Loading reports…

+ ) : !reports.length ? ( +

No reports yet. Publish one using the API docs below.

+ ) : ( +
+
+ Report ID + Access + Updated + URL +
+ {reports.map((report) => ( +
+ {report.report_id} + {report.access === 'password' ? 'Password' : 'Public'} + {new Date(report.updated_at).toLocaleString()} + + Open + +
+ ))} +
+ )} +
+ )} + + {docs && ( +
+
+
+

API documentation

+

{docs.summary}

+
+ {docs.method} +
+
+
+

Endpoint

+ POST /api/v1/urmiport/reports +

Authentication

+

{docs.authentication.header}: {docs.authentication.format}

+

Request body

+
    + {Object.entries(docs.request_body).map(([key, field]) => ( +
  • + {key}{field.required ? ' (required)' : ' (optional)'} — {field.description} + {field.example ? <> Example: {field.example} : null} +
  • + ))} +
+

Notes

+
    + {docs.notes?.map((note) =>
  • {note}
  • )} +
+
+
+

Example request

+
{JSON.stringify({
+                report_group: 'acme-agent-runs',
+                report_id: 'run-abc123',
+                body: '

Agent run complete

', + access: 'public', + }, null, 2)}
+

Example response

+
{JSON.stringify(docs.responses['200']?.example ?? {}, null, 2)}
+ + Try in API Explorer + +
+
+
+ )} + + {showModal && ( + setShowModal(false)} + onCreated={(group) => { + setGroups((current) => [group, ...current]) + setSelectedGroup(group.report_group) + }} + /> + )} +
+ ) +} diff --git a/frontend/src/features/develop/ApiExplorerPage.tsx b/frontend/src/features/develop/ApiExplorerPage.tsx index 0e65fa6..a8c4ddd 100644 --- a/frontend/src/features/develop/ApiExplorerPage.tsx +++ b/frontend/src/features/develop/ApiExplorerPage.tsx @@ -6,6 +6,7 @@ import { integrationsApi, messagesApi, numbersApi, + urmiportApi, voiceApi, type AgentMessageApiDoc, type AgentMessageSendResponse, @@ -13,6 +14,8 @@ import { type MessageApiDoc, type PhoneNumber, type SendMessageResponse, + type UrmiportApiDoc, + type UrmiportPublishResponse, type VoiceApiDocs, type VoiceCall, } from '../../lib/api' @@ -137,34 +140,51 @@ export function ApiExplorerPage() { const [agentTemplate, setAgentTemplate] = useState('') const [agentTo, setAgentTo] = useState('') const [agentCustomerName, setAgentCustomerName] = useState('') + const [agentReportGroup, setAgentReportGroup] = useState('') const [agentReportId, setAgentReportId] = useState('') const [agentResponse, setAgentResponse] = useState(null) const [agentError, setAgentError] = useState('') const [agentRunning, setAgentRunning] = useState(false) + const [urmiportDoc, setUrmiportDoc] = useState(null) + const [urmiportGroup, setUrmiportGroup] = useState('') + const [urmiportReportId, setUrmiportReportId] = useState('') + const [urmiportBody, setUrmiportBody] = useState('

Agent run complete

All checks passed.

') + const [urmiportAccess, setUrmiportAccess] = useState('public') + const [urmiportPassword, setUrmiportPassword] = useState('') + const [urmiportResponse, setUrmiportResponse] = useState(null) + const [urmiportError, setUrmiportError] = useState('') + const [urmiportRunning, setUrmiportRunning] = useState(false) + const [fromDid, setFromDid] = useState('') const [callTo, setCallTo] = useState('') const [voiceResponse, setVoiceResponse] = useState(null) const [voiceError, setVoiceError] = useState('') 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('') useEffect(() => { Promise.allSettled([ messagesApi.docs(), agentMessagesApi.docs(), + urmiportApi.docs(), voiceApi.docs(), integrationsApi.docs(), numbersApi.mine(), ]) .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 (agentResult.status === 'fulfilled') { setAgentDoc(agentResult.value) 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 (integrationResult.status === 'fulfilled') { @@ -206,7 +226,8 @@ export function ApiExplorerPage() { template: agentTemplate || agentDoc.request_body.template.example, to: agentTo || agentDoc.request_body.to.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) return [ `curl -X POST '${API_BASE_URL}${agentDoc.path}' \\`, @@ -214,7 +235,24 @@ export function ApiExplorerPage() { ` -H 'Content-Type: application/json' \\`, ` -d '${body.replace(/'/g, `'\\''`)}'`, ].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_'}' \\`, + ` -H 'Content-Type: application/json' \\`, + ` -d '${body.replace(/'/g, `'\\''`)}'`, + ].join('\n') + }, [apiKey, urmiportAccess, urmiportBody, urmiportDoc, urmiportGroup, urmiportPassword, urmiportReportId]) const voiceCurl = useMemo(() => { if (!voiceDoc) return '' @@ -259,6 +297,7 @@ export function ApiExplorerPage() { template: agentTemplate.trim(), to: agentTo.trim(), customer_name: agentCustomerName.trim(), + report_group: agentReportGroup.trim(), report_id: agentReportId.trim() || undefined, }) setAgentResponse(result) @@ -287,8 +326,28 @@ export function ApiExplorerPage() { } } - async function copyCurl(kind: 'sms' | 'voice' | 'agent') { - const text = kind === 'sms' ? smsCurl : kind === 'agent' ? agentCurl : voiceCurl + async function runUrmiport() { + 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) setCopied(kind) window.setTimeout(() => setCopied(null), 2000) @@ -425,12 +484,14 @@ export function ApiExplorerPage() { key === 'template' ? agentTemplate : key === 'to' ? agentTo : key === 'customer_name' ? agentCustomerName - : agentReportId + : key === 'report_group' ? agentReportGroup + : agentReportId } onChange={(e) => { if (key === 'template') setAgentTemplate(e.target.value) if (key === 'to') setAgentTo(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) }} placeholder={field.example} @@ -475,6 +536,100 @@ export function ApiExplorerPage() { )} + {urmiportDoc && ( + +
+
+
+

{urmiportDoc.title}

+

{urmiportDoc.summary}

+
+ {urmiportDoc.method} +
+ + + + +
+

Request body

+
+
+ +
+
+ +
+
+