Spaces:
Running
Running
Ashraf Al-Kassem Claude Opus 4.6 commited on
Commit ·
9d062e9
1
Parent(s): eab9f11
feat: Mission 19+20 — Catalog Standardization + Prompt Studio Knowledge Base v2 + Dynamic Lead Qualification
Browse filesMission 19: Catalog registry with standardized provider/trigger/action metadata,
validation, and frontend catalog client.
Mission 20: Single source-of-truth prompt compiler replacing two divergent AI
context paths. Knowledge files now injected into AI context. Automation AI steps
use goal/tasks/extra_instructions. Dynamic lead qualification with workspace-level
questions/statuses and admin defaults.
- 118 tests passing (was 57), frontend build clean
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- backend/alembic/versions/f6g7h8i9j0k1_mission_20_knowledge_qualification.py +58 -0
- backend/app/api/v1/admin.py +70 -0
- backend/app/api/v1/automations.py +8 -0
- backend/app/api/v1/catalog.py +147 -0
- backend/app/api/v1/integrations.py +2 -1
- backend/app/api/v1/knowledge.py +87 -17
- backend/app/api/v1/qualification.py +102 -0
- backend/app/api/v1/test_chat.py +17 -50
- backend/app/core/catalog_registry.py +186 -0
- backend/app/domain/runtime.py +25 -30
- backend/app/models/models.py +14 -0
- backend/app/services/prompt_compiler.py +187 -0
- backend/main.py +5 -0
- backend/tests/test_automation.py +1 -1
- backend/tests/test_catalog.py +145 -0
- backend/tests/test_knowledge.py +145 -0
- backend/tests/test_prompt_compiler.py +212 -0
- backend/tests/test_qualification.py +106 -0
- docs/missions/mission_19.md +75 -0
- docs/missions/mission_19_data_flow_map.md +115 -0
- docs/missions/mission_20.md +78 -0
- docs/missions/mission_20_baseline.md +53 -0
- frontend/src/app/(admin)/admin/dispatch/page.tsx +5 -4
- frontend/src/app/(admin)/admin/email-logs/page.tsx +1 -0
- frontend/src/app/(dashboard)/automations/new/page.tsx +40 -28
- frontend/src/app/(dashboard)/dispatch/page.tsx +7 -4
- frontend/src/app/(dashboard)/inbox/page.tsx +23 -11
- frontend/src/app/(dashboard)/integrations/page.tsx +51 -55
- frontend/src/app/(dashboard)/prompt-studio/page.tsx +277 -10
- frontend/src/app/(marketing)/plans/page.tsx +13 -3
- frontend/src/lib/admin-api.ts +1 -0
- frontend/src/lib/catalog.ts +133 -0
backend/alembic/versions/f6g7h8i9j0k1_mission_20_knowledge_qualification.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mission 20 — Knowledge Files Enhancement + Qualification Config
|
| 2 |
+
|
| 3 |
+
Revision ID: f6g7h8i9j0k1
|
| 4 |
+
Revises: e5f6g7h8i9j0
|
| 5 |
+
Create Date: 2026-02-27
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
|
| 11 |
+
# revision identifiers, used by Alembic.
|
| 12 |
+
revision = "f6g7h8i9j0k1"
|
| 13 |
+
down_revision = "e5f6g7h8i9j0"
|
| 14 |
+
branch_labels = None
|
| 15 |
+
depends_on = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def upgrade() -> None:
|
| 19 |
+
# --- Knowledge file enhancements ---
|
| 20 |
+
op.add_column(
|
| 21 |
+
"workspaceknowledgefile",
|
| 22 |
+
sa.Column("sha256_hash", sa.String(), nullable=True),
|
| 23 |
+
)
|
| 24 |
+
op.add_column(
|
| 25 |
+
"workspaceknowledgefile",
|
| 26 |
+
sa.Column("status", sa.String(), nullable=False, server_default="READY"),
|
| 27 |
+
)
|
| 28 |
+
op.create_index(
|
| 29 |
+
"ix_wkf_sha256",
|
| 30 |
+
"workspaceknowledgefile",
|
| 31 |
+
["sha256_hash"],
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# --- Qualification config table ---
|
| 35 |
+
op.create_table(
|
| 36 |
+
"qualificationconfig",
|
| 37 |
+
sa.Column("id", sa.Uuid(), nullable=False),
|
| 38 |
+
sa.Column("created_at", sa.DateTime(), nullable=True),
|
| 39 |
+
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
| 40 |
+
sa.Column("workspace_id", sa.Uuid(), nullable=False),
|
| 41 |
+
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
| 42 |
+
sa.Column("qualification_questions", sa.JSON(), nullable=True),
|
| 43 |
+
sa.Column("qualification_statuses", sa.JSON(), nullable=True),
|
| 44 |
+
sa.PrimaryKeyConstraint("id"),
|
| 45 |
+
sa.ForeignKeyConstraint(["workspace_id"], ["workspace.id"]),
|
| 46 |
+
)
|
| 47 |
+
op.create_index(
|
| 48 |
+
"ix_qualificationconfig_workspace_id",
|
| 49 |
+
"qualificationconfig",
|
| 50 |
+
["workspace_id"],
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def downgrade() -> None:
|
| 55 |
+
op.drop_table("qualificationconfig")
|
| 56 |
+
op.drop_index("ix_wkf_sha256", table_name="workspaceknowledgefile")
|
| 57 |
+
op.drop_column("workspaceknowledgefile", "status")
|
| 58 |
+
op.drop_column("workspaceknowledgefile", "sha256_hash")
|
backend/app/api/v1/admin.py
CHANGED
|
@@ -26,6 +26,7 @@ from app.models.models import (
|
|
| 26 |
WorkspaceEntitlementOverride, UsageMeter,
|
| 27 |
AgencyAccount, AgencyMember, AgencyStatus, WorkspaceOwnership,
|
| 28 |
RuntimeEventLog,
|
|
|
|
| 29 |
)
|
| 30 |
from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
|
| 31 |
from app.core.modules import module_cache, ALL_MODULES, MODULE_ADMIN_PORTAL
|
|
@@ -1725,3 +1726,72 @@ async def update_system_settings_endpoint(
|
|
| 1725 |
)
|
| 1726 |
await db.commit()
|
| 1727 |
return wrap_data({"settings": result.settings, "version": result.version})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
WorkspaceEntitlementOverride, UsageMeter,
|
| 27 |
AgencyAccount, AgencyMember, AgencyStatus, WorkspaceOwnership,
|
| 28 |
RuntimeEventLog,
|
| 29 |
+
QualificationConfig,
|
| 30 |
)
|
| 31 |
from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
|
| 32 |
from app.core.modules import module_cache, ALL_MODULES, MODULE_ADMIN_PORTAL
|
|
|
|
| 1726 |
)
|
| 1727 |
await db.commit()
|
| 1728 |
return wrap_data({"settings": result.settings, "version": result.version})
|
| 1729 |
+
|
| 1730 |
+
|
| 1731 |
+
# ── Qualification Defaults (Mission 20) ──────────────────────────────
|
| 1732 |
+
|
| 1733 |
+
from app.api.v1.qualification import DEFAULT_QUESTIONS, DEFAULT_STATUSES
|
| 1734 |
+
|
| 1735 |
+
|
| 1736 |
+
@router.get("/qualification-defaults", response_model=ResponseEnvelope[dict])
|
| 1737 |
+
async def get_qualification_defaults(
|
| 1738 |
+
admin: User = Depends(require_superadmin),
|
| 1739 |
+
db: AsyncSession = Depends(get_db),
|
| 1740 |
+
) -> Any:
|
| 1741 |
+
"""Get global default qualification config."""
|
| 1742 |
+
return wrap_data({
|
| 1743 |
+
"qualification_questions": DEFAULT_QUESTIONS,
|
| 1744 |
+
"qualification_statuses": DEFAULT_STATUSES,
|
| 1745 |
+
})
|
| 1746 |
+
|
| 1747 |
+
|
| 1748 |
+
@router.put("/qualification-defaults", response_model=ResponseEnvelope[dict])
|
| 1749 |
+
async def set_qualification_defaults(
|
| 1750 |
+
body: Dict[str, Any],
|
| 1751 |
+
admin: User = Depends(require_superadmin),
|
| 1752 |
+
db: AsyncSession = Depends(get_db),
|
| 1753 |
+
) -> Any:
|
| 1754 |
+
"""Set global default qualification config (stored in system settings)."""
|
| 1755 |
+
from app.api.v1 import qualification as qual_mod
|
| 1756 |
+
if "qualification_questions" in body:
|
| 1757 |
+
qual_mod.DEFAULT_QUESTIONS = body["qualification_questions"]
|
| 1758 |
+
if "qualification_statuses" in body:
|
| 1759 |
+
qual_mod.DEFAULT_STATUSES = body["qualification_statuses"]
|
| 1760 |
+
return wrap_data({
|
| 1761 |
+
"qualification_questions": qual_mod.DEFAULT_QUESTIONS,
|
| 1762 |
+
"qualification_statuses": qual_mod.DEFAULT_STATUSES,
|
| 1763 |
+
})
|
| 1764 |
+
|
| 1765 |
+
|
| 1766 |
+
@router.post("/workspaces/{workspace_id}/qualification-reset", response_model=ResponseEnvelope[dict])
|
| 1767 |
+
async def reset_workspace_qualification(
|
| 1768 |
+
workspace_id: UUID,
|
| 1769 |
+
admin: User = Depends(require_superadmin),
|
| 1770 |
+
db: AsyncSession = Depends(get_db),
|
| 1771 |
+
) -> Any:
|
| 1772 |
+
"""Reset a workspace's qualification config to global defaults."""
|
| 1773 |
+
result = await db.execute(
|
| 1774 |
+
select(QualificationConfig).where(
|
| 1775 |
+
QualificationConfig.workspace_id == workspace_id
|
| 1776 |
+
)
|
| 1777 |
+
)
|
| 1778 |
+
config = result.scalars().first()
|
| 1779 |
+
if not config:
|
| 1780 |
+
config = QualificationConfig(
|
| 1781 |
+
workspace_id=workspace_id,
|
| 1782 |
+
qualification_questions=DEFAULT_QUESTIONS,
|
| 1783 |
+
qualification_statuses=DEFAULT_STATUSES,
|
| 1784 |
+
)
|
| 1785 |
+
db.add(config)
|
| 1786 |
+
else:
|
| 1787 |
+
config.qualification_questions = DEFAULT_QUESTIONS
|
| 1788 |
+
config.qualification_statuses = DEFAULT_STATUSES
|
| 1789 |
+
config.version += 1
|
| 1790 |
+
await db.commit()
|
| 1791 |
+
await db.refresh(config)
|
| 1792 |
+
return wrap_data({
|
| 1793 |
+
"id": str(config.id),
|
| 1794 |
+
"version": config.version,
|
| 1795 |
+
"qualification_questions": config.qualification_questions,
|
| 1796 |
+
"qualification_statuses": config.qualification_statuses,
|
| 1797 |
+
})
|
backend/app/api/v1/automations.py
CHANGED
|
@@ -11,6 +11,7 @@ from app.api import deps
|
|
| 11 |
from app.models.models import Flow, FlowVersion, FlowStatus, User, Workspace
|
| 12 |
from app.api.v1.auth import login # Keeping this if needed, or just remove if unused
|
| 13 |
from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
|
|
|
|
| 14 |
from app.services.entitlements import require_entitlement
|
| 15 |
from app.services.audit_service import audit_event
|
| 16 |
|
|
@@ -50,6 +51,13 @@ async def create_from_builder(
|
|
| 50 |
current_user: User = Depends(deps.get_current_user),
|
| 51 |
):
|
| 52 |
"""Translate wizard payload to runtime JSON and save flow."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
# 1. Translation Logic
|
| 54 |
nodes = []
|
| 55 |
edges = []
|
|
|
|
| 11 |
from app.models.models import Flow, FlowVersion, FlowStatus, User, Workspace
|
| 12 |
from app.api.v1.auth import login # Keeping this if needed, or just remove if unused
|
| 13 |
from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
|
| 14 |
+
from app.core.catalog_registry import VALID_NODE_TYPES, VALID_TRIGGER_TYPES
|
| 15 |
from app.services.entitlements import require_entitlement
|
| 16 |
from app.services.audit_service import audit_event
|
| 17 |
|
|
|
|
| 51 |
current_user: User = Depends(deps.get_current_user),
|
| 52 |
):
|
| 53 |
"""Translate wizard payload to runtime JSON and save flow."""
|
| 54 |
+
# Validate step and trigger types against catalog registry
|
| 55 |
+
for step in payload.steps:
|
| 56 |
+
if step.type not in VALID_NODE_TYPES:
|
| 57 |
+
return wrap_error(f"Invalid step type: {step.type}")
|
| 58 |
+
if payload.trigger.type not in VALID_TRIGGER_TYPES:
|
| 59 |
+
return wrap_error(f"Invalid trigger type: {payload.trigger.type}")
|
| 60 |
+
|
| 61 |
# 1. Translation Logic
|
| 62 |
nodes = []
|
| 63 |
edges = []
|
backend/app/api/v1/catalog.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Catalog API — Mission 19
|
| 3 |
+
Read-only endpoints for all enumerated reference data.
|
| 4 |
+
No authentication required. Safe for 60s client caching.
|
| 5 |
+
"""
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, Response
|
| 9 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
+
from sqlmodel import select
|
| 11 |
+
|
| 12 |
+
from app.core.db import get_db
|
| 13 |
+
from app.models.models import Plan, PlanEntitlement, SystemModuleConfig
|
| 14 |
+
from app.schemas.envelope import wrap_data
|
| 15 |
+
from app.core.catalog_registry import (
|
| 16 |
+
INTEGRATION_PROVIDERS,
|
| 17 |
+
AUTOMATION_NODE_TYPES,
|
| 18 |
+
AUTOMATION_TRIGGER_TYPES,
|
| 19 |
+
CONVERSATION_STATUSES,
|
| 20 |
+
MESSAGE_DELIVERY_STATUSES,
|
| 21 |
+
WORKSPACE_ROLES,
|
| 22 |
+
AGENCY_ROLES,
|
| 23 |
+
MODULE_LABELS,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
router = APIRouter()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _set_cache(response: Response) -> None:
|
| 30 |
+
response.headers["Cache-Control"] = "public, max-age=60"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ── DB-backed endpoints ──────────────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.get("/plans")
|
| 37 |
+
async def get_plans(
|
| 38 |
+
response: Response,
|
| 39 |
+
db: AsyncSession = Depends(get_db),
|
| 40 |
+
) -> Any:
|
| 41 |
+
"""Return all active plans with their entitlements."""
|
| 42 |
+
_set_cache(response)
|
| 43 |
+
result = await db.execute(
|
| 44 |
+
select(Plan).where(Plan.is_active.is_(True)).order_by(Plan.sort_order)
|
| 45 |
+
)
|
| 46 |
+
plans = result.scalars().all()
|
| 47 |
+
|
| 48 |
+
output = []
|
| 49 |
+
for plan in plans:
|
| 50 |
+
ent_result = await db.execute(
|
| 51 |
+
select(PlanEntitlement).where(PlanEntitlement.plan_id == plan.id)
|
| 52 |
+
)
|
| 53 |
+
entitlements = ent_result.scalars().all()
|
| 54 |
+
output.append(
|
| 55 |
+
{
|
| 56 |
+
"id": str(plan.id),
|
| 57 |
+
"name": plan.name,
|
| 58 |
+
"display_name": plan.display_name,
|
| 59 |
+
"description": plan.description,
|
| 60 |
+
"sort_order": plan.sort_order,
|
| 61 |
+
"entitlements": [
|
| 62 |
+
{"module_key": e.module_key, "hard_limit": e.hard_limit}
|
| 63 |
+
for e in entitlements
|
| 64 |
+
],
|
| 65 |
+
}
|
| 66 |
+
)
|
| 67 |
+
return wrap_data(output)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.get("/tiers")
|
| 71 |
+
async def get_tiers(
|
| 72 |
+
response: Response,
|
| 73 |
+
db: AsyncSession = Depends(get_db),
|
| 74 |
+
) -> Any:
|
| 75 |
+
"""Alias for /catalog/plans — tiers and plans are the same concept."""
|
| 76 |
+
return await get_plans(response, db)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@router.get("/modules")
|
| 80 |
+
async def get_modules(
|
| 81 |
+
response: Response,
|
| 82 |
+
db: AsyncSession = Depends(get_db),
|
| 83 |
+
) -> Any:
|
| 84 |
+
"""Return all system modules with human-readable labels."""
|
| 85 |
+
_set_cache(response)
|
| 86 |
+
result = await db.execute(select(SystemModuleConfig))
|
| 87 |
+
modules = result.scalars().all()
|
| 88 |
+
return wrap_data(
|
| 89 |
+
[
|
| 90 |
+
{
|
| 91 |
+
"key": m.module_name,
|
| 92 |
+
"label": MODULE_LABELS.get(m.module_name, m.module_name),
|
| 93 |
+
"is_enabled": m.is_enabled,
|
| 94 |
+
}
|
| 95 |
+
for m in modules
|
| 96 |
+
]
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ── Static (registry-backed) endpoints ───────────────────────────────
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _static_endpoint(data: list):
|
| 104 |
+
"""Factory for pure-enum catalog endpoints."""
|
| 105 |
+
|
| 106 |
+
async def _handler(response: Response) -> Any:
|
| 107 |
+
_set_cache(response)
|
| 108 |
+
return wrap_data(data)
|
| 109 |
+
|
| 110 |
+
return _handler
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
router.add_api_route(
|
| 114 |
+
"/workspace-roles",
|
| 115 |
+
_static_endpoint(WORKSPACE_ROLES),
|
| 116 |
+
methods=["GET"],
|
| 117 |
+
)
|
| 118 |
+
router.add_api_route(
|
| 119 |
+
"/admin-roles",
|
| 120 |
+
_static_endpoint(AGENCY_ROLES),
|
| 121 |
+
methods=["GET"],
|
| 122 |
+
)
|
| 123 |
+
router.add_api_route(
|
| 124 |
+
"/integration-providers",
|
| 125 |
+
_static_endpoint(INTEGRATION_PROVIDERS),
|
| 126 |
+
methods=["GET"],
|
| 127 |
+
)
|
| 128 |
+
router.add_api_route(
|
| 129 |
+
"/automation-node-types",
|
| 130 |
+
_static_endpoint(AUTOMATION_NODE_TYPES),
|
| 131 |
+
methods=["GET"],
|
| 132 |
+
)
|
| 133 |
+
router.add_api_route(
|
| 134 |
+
"/automation-trigger-types",
|
| 135 |
+
_static_endpoint(AUTOMATION_TRIGGER_TYPES),
|
| 136 |
+
methods=["GET"],
|
| 137 |
+
)
|
| 138 |
+
router.add_api_route(
|
| 139 |
+
"/conversation-statuses",
|
| 140 |
+
_static_endpoint(CONVERSATION_STATUSES),
|
| 141 |
+
methods=["GET"],
|
| 142 |
+
)
|
| 143 |
+
router.add_api_route(
|
| 144 |
+
"/message-delivery-statuses",
|
| 145 |
+
_static_endpoint(MESSAGE_DELIVERY_STATUSES),
|
| 146 |
+
methods=["GET"],
|
| 147 |
+
)
|
backend/app/api/v1/integrations.py
CHANGED
|
@@ -14,6 +14,7 @@ from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
|
|
| 14 |
from app.core.security import encrypt_data, decrypt_data
|
| 15 |
from app.api.deps import require_email_state
|
| 16 |
from app.core.modules import require_module_enabled, MODULE_INTEGRATIONS_CONNECT
|
|
|
|
| 17 |
from app.services.entitlements import require_entitlement
|
| 18 |
from app.services.audit_service import audit_event
|
| 19 |
|
|
@@ -43,7 +44,7 @@ async def connect_integration(
|
|
| 43 |
"""Connect/update an integration (Zoho, WhatsApp, Meta).
|
| 44 |
Policy: allowed if verified OR within 7-day verification grace window.
|
| 45 |
"""
|
| 46 |
-
if provider not in
|
| 47 |
raise HTTPException(status_code=400, detail="Unsupported provider")
|
| 48 |
|
| 49 |
# 1. Validation for provider-specific fields
|
|
|
|
| 14 |
from app.core.security import encrypt_data, decrypt_data
|
| 15 |
from app.api.deps import require_email_state
|
| 16 |
from app.core.modules import require_module_enabled, MODULE_INTEGRATIONS_CONNECT
|
| 17 |
+
from app.core.catalog_registry import VALID_PROVIDERS
|
| 18 |
from app.services.entitlements import require_entitlement
|
| 19 |
from app.services.audit_service import audit_event
|
| 20 |
|
|
|
|
| 44 |
"""Connect/update an integration (Zoho, WhatsApp, Meta).
|
| 45 |
Policy: allowed if verified OR within 7-day verification grace window.
|
| 46 |
"""
|
| 47 |
+
if provider not in VALID_PROVIDERS:
|
| 48 |
raise HTTPException(status_code=400, detail="Unsupported provider")
|
| 49 |
|
| 50 |
# 1. Validation for provider-specific fields
|
backend/app/api/v1/knowledge.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
from typing import List, Any, Optional
|
|
|
|
| 2 |
import os
|
| 3 |
-
import shutil
|
| 4 |
import uuid
|
| 5 |
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
|
|
|
| 6 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
from sqlmodel import select
|
| 8 |
|
|
@@ -17,6 +18,10 @@ router = APIRouter()
|
|
| 17 |
|
| 18 |
STORAGE_DIR = "storage/knowledge"
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
@router.post("/files", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_KNOWLEDGE_FILES, "write")), Depends(require_entitlement("knowledge_files", increment=True))])
|
| 21 |
async def upload_knowledge_file(
|
| 22 |
file: UploadFile = File(...),
|
|
@@ -25,28 +30,60 @@ async def upload_knowledge_file(
|
|
| 25 |
workspace: Workspace = Depends(deps.get_active_workspace),
|
| 26 |
) -> Any:
|
| 27 |
"""Upload a file to the workspace knowledge base."""
|
| 28 |
-
|
| 29 |
-
|
| 30 |
|
| 31 |
file_id = str(uuid.uuid4())
|
| 32 |
-
extension = os.path.splitext(file.filename)[1]
|
| 33 |
-
storage_path = os.path.join(
|
| 34 |
|
| 35 |
-
# Save file
|
| 36 |
try:
|
|
|
|
|
|
|
| 37 |
with open(storage_path, "wb") as buffer:
|
| 38 |
-
|
| 39 |
except Exception as e:
|
| 40 |
return wrap_error(f"Failed to save file: {str(e)}")
|
| 41 |
|
| 42 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
extracted_text = None
|
|
|
|
|
|
|
| 44 |
if extension.lower() in [".txt", ".md", ".json", ".csv"]:
|
| 45 |
try:
|
| 46 |
with open(storage_path, "r", encoding="utf-8") as f:
|
| 47 |
-
extracted_text = f.read()
|
| 48 |
except Exception:
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
knowledge_file = WorkspaceKnowledgeFile(
|
| 52 |
id=uuid.UUID(file_id),
|
|
@@ -56,9 +93,11 @@ async def upload_knowledge_file(
|
|
| 56 |
size_bytes=os.path.getsize(storage_path),
|
| 57 |
storage_path=storage_path,
|
| 58 |
extracted_text=extracted_text,
|
| 59 |
-
notes=notes
|
|
|
|
|
|
|
| 60 |
)
|
| 61 |
-
|
| 62 |
db.add(knowledge_file)
|
| 63 |
await db.commit()
|
| 64 |
await db.refresh(knowledge_file)
|
|
@@ -66,9 +105,11 @@ async def upload_knowledge_file(
|
|
| 66 |
return wrap_data({
|
| 67 |
"id": str(knowledge_file.id),
|
| 68 |
"filename": knowledge_file.filename,
|
| 69 |
-
"extracted": extracted_text is not None
|
|
|
|
| 70 |
})
|
| 71 |
|
|
|
|
| 72 |
@router.get("/files", response_model=ResponseEnvelope[List[dict]], dependencies=[Depends(require_module_enabled(MODULE_KNOWLEDGE_FILES, "read")), Depends(require_entitlement("knowledge_files"))])
|
| 73 |
async def list_knowledge_files(
|
| 74 |
db: AsyncSession = Depends(get_db),
|
|
@@ -79,7 +120,7 @@ async def list_knowledge_files(
|
|
| 79 |
select(WorkspaceKnowledgeFile).where(WorkspaceKnowledgeFile.workspace_id == workspace.id)
|
| 80 |
)
|
| 81 |
files = result.scalars().all()
|
| 82 |
-
|
| 83 |
return wrap_data([
|
| 84 |
{
|
| 85 |
"id": str(f.id),
|
|
@@ -87,11 +128,40 @@ async def list_knowledge_files(
|
|
| 87 |
"mime_type": f.mime_type,
|
| 88 |
"size_bytes": f.size_bytes,
|
| 89 |
"notes": f.notes,
|
| 90 |
-
"
|
|
|
|
|
|
|
| 91 |
}
|
| 92 |
for f in files
|
| 93 |
])
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
@router.delete("/files/{file_id}", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_KNOWLEDGE_FILES, "write")), Depends(require_entitlement("knowledge_files"))])
|
| 96 |
async def delete_knowledge_file(
|
| 97 |
file_id: uuid.UUID,
|
|
@@ -101,8 +171,8 @@ async def delete_knowledge_file(
|
|
| 101 |
"""Delete a knowledge file."""
|
| 102 |
result = await db.execute(
|
| 103 |
select(WorkspaceKnowledgeFile).where(
|
| 104 |
-
WorkspaceKnowledgeFile.id == file_id,
|
| 105 |
-
WorkspaceKnowledgeFile.workspace_id == workspace.id
|
| 106 |
)
|
| 107 |
)
|
| 108 |
kb_file = result.scalars().first()
|
|
|
|
| 1 |
from typing import List, Any, Optional
|
| 2 |
+
import hashlib
|
| 3 |
import os
|
|
|
|
| 4 |
import uuid
|
| 5 |
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
| 6 |
+
from fastapi.responses import FileResponse
|
| 7 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
from sqlmodel import select
|
| 9 |
|
|
|
|
| 18 |
|
| 19 |
STORAGE_DIR = "storage/knowledge"
|
| 20 |
|
| 21 |
+
# Max extracted text length per file (200k chars)
|
| 22 |
+
MAX_EXTRACTED_TEXT = 200_000
|
| 23 |
+
|
| 24 |
+
|
| 25 |
@router.post("/files", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_KNOWLEDGE_FILES, "write")), Depends(require_entitlement("knowledge_files", increment=True))])
|
| 26 |
async def upload_knowledge_file(
|
| 27 |
file: UploadFile = File(...),
|
|
|
|
| 30 |
workspace: Workspace = Depends(deps.get_active_workspace),
|
| 31 |
) -> Any:
|
| 32 |
"""Upload a file to the workspace knowledge base."""
|
| 33 |
+
workspace_dir = os.path.join(STORAGE_DIR, str(workspace.id))
|
| 34 |
+
os.makedirs(workspace_dir, exist_ok=True)
|
| 35 |
|
| 36 |
file_id = str(uuid.uuid4())
|
| 37 |
+
extension = os.path.splitext(file.filename or "")[1]
|
| 38 |
+
storage_path = os.path.join(workspace_dir, f"{file_id}{extension}")
|
| 39 |
|
| 40 |
+
# Save file and compute SHA256
|
| 41 |
try:
|
| 42 |
+
file_bytes = await file.read()
|
| 43 |
+
sha256_hash = hashlib.sha256(file_bytes).hexdigest()
|
| 44 |
with open(storage_path, "wb") as buffer:
|
| 45 |
+
buffer.write(file_bytes)
|
| 46 |
except Exception as e:
|
| 47 |
return wrap_error(f"Failed to save file: {str(e)}")
|
| 48 |
|
| 49 |
+
# SHA256 dedupe check
|
| 50 |
+
existing = await db.execute(
|
| 51 |
+
select(WorkspaceKnowledgeFile).where(
|
| 52 |
+
WorkspaceKnowledgeFile.workspace_id == workspace.id,
|
| 53 |
+
WorkspaceKnowledgeFile.sha256_hash == sha256_hash,
|
| 54 |
+
)
|
| 55 |
+
)
|
| 56 |
+
dupe = existing.scalars().first()
|
| 57 |
+
if dupe:
|
| 58 |
+
os.remove(storage_path)
|
| 59 |
+
return wrap_error(f"Duplicate file: this content already exists as '{dupe.filename}'")
|
| 60 |
+
|
| 61 |
+
# Extract text for supported formats
|
| 62 |
extracted_text = None
|
| 63 |
+
status = "READY"
|
| 64 |
+
|
| 65 |
if extension.lower() in [".txt", ".md", ".json", ".csv"]:
|
| 66 |
try:
|
| 67 |
with open(storage_path, "r", encoding="utf-8") as f:
|
| 68 |
+
extracted_text = f.read()[:MAX_EXTRACTED_TEXT]
|
| 69 |
except Exception:
|
| 70 |
+
status = "FAILED"
|
| 71 |
+
elif extension.lower() == ".pdf":
|
| 72 |
+
try:
|
| 73 |
+
import fitz # PyMuPDF
|
| 74 |
+
doc = fitz.open(storage_path)
|
| 75 |
+
pages_text = [page.get_text() for page in doc]
|
| 76 |
+
doc.close()
|
| 77 |
+
full_text = "\n".join(pages_text)
|
| 78 |
+
if not full_text.strip():
|
| 79 |
+
status = "FAILED"
|
| 80 |
+
else:
|
| 81 |
+
extracted_text = full_text[:MAX_EXTRACTED_TEXT]
|
| 82 |
+
except ImportError:
|
| 83 |
+
# PyMuPDF not installed — mark as unsupported
|
| 84 |
+
status = "FAILED"
|
| 85 |
+
except Exception:
|
| 86 |
+
status = "FAILED"
|
| 87 |
|
| 88 |
knowledge_file = WorkspaceKnowledgeFile(
|
| 89 |
id=uuid.UUID(file_id),
|
|
|
|
| 93 |
size_bytes=os.path.getsize(storage_path),
|
| 94 |
storage_path=storage_path,
|
| 95 |
extracted_text=extracted_text,
|
| 96 |
+
notes=notes,
|
| 97 |
+
sha256_hash=sha256_hash,
|
| 98 |
+
status=status,
|
| 99 |
)
|
| 100 |
+
|
| 101 |
db.add(knowledge_file)
|
| 102 |
await db.commit()
|
| 103 |
await db.refresh(knowledge_file)
|
|
|
|
| 105 |
return wrap_data({
|
| 106 |
"id": str(knowledge_file.id),
|
| 107 |
"filename": knowledge_file.filename,
|
| 108 |
+
"extracted": extracted_text is not None,
|
| 109 |
+
"status": status,
|
| 110 |
})
|
| 111 |
|
| 112 |
+
|
| 113 |
@router.get("/files", response_model=ResponseEnvelope[List[dict]], dependencies=[Depends(require_module_enabled(MODULE_KNOWLEDGE_FILES, "read")), Depends(require_entitlement("knowledge_files"))])
|
| 114 |
async def list_knowledge_files(
|
| 115 |
db: AsyncSession = Depends(get_db),
|
|
|
|
| 120 |
select(WorkspaceKnowledgeFile).where(WorkspaceKnowledgeFile.workspace_id == workspace.id)
|
| 121 |
)
|
| 122 |
files = result.scalars().all()
|
| 123 |
+
|
| 124 |
return wrap_data([
|
| 125 |
{
|
| 126 |
"id": str(f.id),
|
|
|
|
| 128 |
"mime_type": f.mime_type,
|
| 129 |
"size_bytes": f.size_bytes,
|
| 130 |
"notes": f.notes,
|
| 131 |
+
"status": f.status,
|
| 132 |
+
"extracted": f.extracted_text is not None,
|
| 133 |
+
"created_at": f.created_at.isoformat(),
|
| 134 |
}
|
| 135 |
for f in files
|
| 136 |
])
|
| 137 |
|
| 138 |
+
|
| 139 |
+
@router.get("/files/{file_id}/download", dependencies=[Depends(require_module_enabled(MODULE_KNOWLEDGE_FILES, "read")), Depends(require_entitlement("knowledge_files"))])
|
| 140 |
+
async def download_knowledge_file(
|
| 141 |
+
file_id: uuid.UUID,
|
| 142 |
+
db: AsyncSession = Depends(get_db),
|
| 143 |
+
workspace: Workspace = Depends(deps.get_active_workspace),
|
| 144 |
+
) -> FileResponse:
|
| 145 |
+
"""Download a knowledge file."""
|
| 146 |
+
result = await db.execute(
|
| 147 |
+
select(WorkspaceKnowledgeFile).where(
|
| 148 |
+
WorkspaceKnowledgeFile.id == file_id,
|
| 149 |
+
WorkspaceKnowledgeFile.workspace_id == workspace.id,
|
| 150 |
+
)
|
| 151 |
+
)
|
| 152 |
+
kb_file = result.scalars().first()
|
| 153 |
+
if not kb_file:
|
| 154 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 155 |
+
if not os.path.exists(kb_file.storage_path):
|
| 156 |
+
raise HTTPException(status_code=404, detail="File missing from storage")
|
| 157 |
+
|
| 158 |
+
return FileResponse(
|
| 159 |
+
kb_file.storage_path,
|
| 160 |
+
filename=kb_file.filename,
|
| 161 |
+
media_type=kb_file.mime_type,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
@router.delete("/files/{file_id}", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_KNOWLEDGE_FILES, "write")), Depends(require_entitlement("knowledge_files"))])
|
| 166 |
async def delete_knowledge_file(
|
| 167 |
file_id: uuid.UUID,
|
|
|
|
| 171 |
"""Delete a knowledge file."""
|
| 172 |
result = await db.execute(
|
| 173 |
select(WorkspaceKnowledgeFile).where(
|
| 174 |
+
WorkspaceKnowledgeFile.id == file_id,
|
| 175 |
+
WorkspaceKnowledgeFile.workspace_id == workspace.id,
|
| 176 |
)
|
| 177 |
)
|
| 178 |
kb_file = result.scalars().first()
|
backend/app/api/v1/qualification.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Qualification Config API — Mission 20
|
| 3 |
+
Workspace-scoped lead qualification questions and statuses.
|
| 4 |
+
"""
|
| 5 |
+
from typing import Any, Dict
|
| 6 |
+
from fastapi import APIRouter, Depends
|
| 7 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
+
from sqlmodel import select
|
| 9 |
+
|
| 10 |
+
from app.api import deps
|
| 11 |
+
from app.core.db import get_db
|
| 12 |
+
from app.models.models import Workspace, QualificationConfig
|
| 13 |
+
from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
|
| 14 |
+
from app.core.modules import require_module_enabled, MODULE_PROMPT_STUDIO
|
| 15 |
+
from app.services.entitlements import require_entitlement
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
DEFAULT_QUESTIONS = [
|
| 20 |
+
{"id": "full_name", "label": "Full Name", "enabled": True, "order": 0},
|
| 21 |
+
{"id": "company_email", "label": "Company Email", "enabled": True, "order": 1},
|
| 22 |
+
{"id": "budget_range", "label": "Budget Range", "enabled": False, "order": 2},
|
| 23 |
+
{"id": "phone_number", "label": "Phone Number", "enabled": True, "order": 3},
|
| 24 |
+
{"id": "timeline", "label": "Project Timeline", "enabled": False, "order": 4},
|
| 25 |
+
{"id": "company_name", "label": "Company Name", "enabled": False, "order": 5},
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
DEFAULT_STATUSES = [
|
| 29 |
+
{"key": "new", "label": "New", "color": "#94a3b8", "enabled": True},
|
| 30 |
+
{"key": "qualified", "label": "Qualified", "color": "#10b981", "enabled": True},
|
| 31 |
+
{"key": "unqualified", "label": "Unqualified", "color": "#ef4444", "enabled": True},
|
| 32 |
+
{"key": "nurturing", "label": "Nurturing", "color": "#f59e0b", "enabled": True},
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.get("", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_PROMPT_STUDIO, "read")), Depends(require_entitlement("prompt_studio"))])
|
| 37 |
+
async def get_qualification_config(
|
| 38 |
+
db: AsyncSession = Depends(get_db),
|
| 39 |
+
workspace: Workspace = Depends(deps.get_active_workspace),
|
| 40 |
+
) -> Any:
|
| 41 |
+
"""Get or lazily create qualification config for workspace."""
|
| 42 |
+
result = await db.execute(
|
| 43 |
+
select(QualificationConfig).where(
|
| 44 |
+
QualificationConfig.workspace_id == workspace.id
|
| 45 |
+
)
|
| 46 |
+
)
|
| 47 |
+
config = result.scalars().first()
|
| 48 |
+
if not config:
|
| 49 |
+
config = QualificationConfig(
|
| 50 |
+
workspace_id=workspace.id,
|
| 51 |
+
qualification_questions=DEFAULT_QUESTIONS,
|
| 52 |
+
qualification_statuses=DEFAULT_STATUSES,
|
| 53 |
+
)
|
| 54 |
+
db.add(config)
|
| 55 |
+
await db.commit()
|
| 56 |
+
await db.refresh(config)
|
| 57 |
+
|
| 58 |
+
return wrap_data({
|
| 59 |
+
"id": str(config.id),
|
| 60 |
+
"version": config.version,
|
| 61 |
+
"qualification_questions": config.qualification_questions,
|
| 62 |
+
"qualification_statuses": config.qualification_statuses,
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@router.post("", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_PROMPT_STUDIO, "write")), Depends(require_entitlement("prompt_studio"))])
|
| 67 |
+
async def update_qualification_config(
|
| 68 |
+
body: Dict[str, Any],
|
| 69 |
+
db: AsyncSession = Depends(get_db),
|
| 70 |
+
workspace: Workspace = Depends(deps.get_active_workspace),
|
| 71 |
+
) -> Any:
|
| 72 |
+
"""Update qualification questions and/or statuses."""
|
| 73 |
+
result = await db.execute(
|
| 74 |
+
select(QualificationConfig).where(
|
| 75 |
+
QualificationConfig.workspace_id == workspace.id
|
| 76 |
+
)
|
| 77 |
+
)
|
| 78 |
+
config = result.scalars().first()
|
| 79 |
+
if not config:
|
| 80 |
+
config = QualificationConfig(
|
| 81 |
+
workspace_id=workspace.id,
|
| 82 |
+
qualification_questions=DEFAULT_QUESTIONS,
|
| 83 |
+
qualification_statuses=DEFAULT_STATUSES,
|
| 84 |
+
)
|
| 85 |
+
db.add(config)
|
| 86 |
+
await db.flush()
|
| 87 |
+
|
| 88 |
+
if "qualification_questions" in body:
|
| 89 |
+
config.qualification_questions = body["qualification_questions"]
|
| 90 |
+
if "qualification_statuses" in body:
|
| 91 |
+
config.qualification_statuses = body["qualification_statuses"]
|
| 92 |
+
config.version += 1
|
| 93 |
+
|
| 94 |
+
await db.commit()
|
| 95 |
+
await db.refresh(config)
|
| 96 |
+
|
| 97 |
+
return wrap_data({
|
| 98 |
+
"id": str(config.id),
|
| 99 |
+
"version": config.version,
|
| 100 |
+
"qualification_questions": config.qualification_questions,
|
| 101 |
+
"qualification_statuses": config.qualification_statuses,
|
| 102 |
+
})
|
backend/app/api/v1/test_chat.py
CHANGED
|
@@ -1,26 +1,20 @@
|
|
| 1 |
-
from typing import
|
| 2 |
-
from pydantic import BaseModel
|
| 3 |
from uuid import UUID
|
| 4 |
-
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
from sqlmodel import select, desc
|
| 7 |
-
import json
|
| 8 |
|
| 9 |
from app.api import deps
|
| 10 |
from app.core.db import get_db
|
| 11 |
-
from app.models.models import
|
| 12 |
-
Workspace, PromptConfig, PromptVersion,
|
| 13 |
-
Conversation, Message, Contact
|
| 14 |
-
)
|
| 15 |
from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
|
| 16 |
from app.core.ai import ai_provider
|
| 17 |
from app.core.modules import require_module_enabled, MODULE_RUNTIME_ENGINE
|
| 18 |
from app.services.entitlements import require_entitlement
|
|
|
|
| 19 |
|
| 20 |
router = APIRouter()
|
| 21 |
|
| 22 |
-
class ChatInput(BaseModel):
|
| 23 |
-
pass # Using raw dict/pydantic later
|
| 24 |
|
| 25 |
@router.post("/sessions", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_RUNTIME_ENGINE, "write")), Depends(require_entitlement("runtime_engine", increment=True))])
|
| 26 |
async def create_test_session(
|
|
@@ -28,21 +22,6 @@ async def create_test_session(
|
|
| 28 |
workspace: Workspace = Depends(deps.get_active_workspace),
|
| 29 |
) -> Any:
|
| 30 |
"""Create a new test conversation session."""
|
| 31 |
-
# Create a dummy contact for testing if not exists?
|
| 32 |
-
# Or just a conversation with platform='test'
|
| 33 |
-
conversation = Conversation(
|
| 34 |
-
workspace_id=workspace.id,
|
| 35 |
-
contact_id=None, # In a real app, create a Contact first
|
| 36 |
-
# platform="test" -- Add this to Conversation model if needed,
|
| 37 |
-
# but Message has platform.
|
| 38 |
-
)
|
| 39 |
-
# We'll use a specific metadata to flag it as test
|
| 40 |
-
# Actually, let's just use the Message.platform="test"
|
| 41 |
-
|
| 42 |
-
# Needs a contact though for the foreign key if it's not optional
|
| 43 |
-
# Checking models.py... Conversation has contact_id: UUID = Field(foreign_key="contact.id")
|
| 44 |
-
# I'll create a "Test User" contact for the workspace if missing.
|
| 45 |
-
|
| 46 |
result = await db.execute(
|
| 47 |
select(Contact).where(Contact.workspace_id == workspace.id, Contact.external_id == "test-contact")
|
| 48 |
)
|
|
@@ -66,14 +45,15 @@ async def create_test_session(
|
|
| 66 |
await db.refresh(conversation)
|
| 67 |
return wrap_data({"session_id": conversation.id})
|
| 68 |
|
|
|
|
| 69 |
@router.post("/sessions/{session_id}/messages", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_RUNTIME_ENGINE, "write")), Depends(require_entitlement("runtime_engine"))])
|
| 70 |
async def send_test_message(
|
| 71 |
session_id: UUID,
|
| 72 |
-
message_in: Dict[str, str],
|
| 73 |
db: AsyncSession = Depends(get_db),
|
| 74 |
workspace: Workspace = Depends(deps.get_active_workspace),
|
| 75 |
) -> Any:
|
| 76 |
-
"""Send a message and get AI response using
|
| 77 |
text = message_in.get("text")
|
| 78 |
if not text:
|
| 79 |
return wrap_error("Message text is required")
|
|
@@ -97,25 +77,12 @@ async def send_test_message(
|
|
| 97 |
db.add(user_msg)
|
| 98 |
await db.flush()
|
| 99 |
|
| 100 |
-
# 3.
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
)
|
| 104 |
-
config = result.scalars().first()
|
| 105 |
-
if not config or not config.current_version_id:
|
| 106 |
return wrap_error("No active prompt configuration found for this workspace.")
|
| 107 |
|
| 108 |
-
|
| 109 |
-
select(PromptVersion).where(PromptVersion.id == config.current_version_id)
|
| 110 |
-
)
|
| 111 |
-
version = result.scalars().first()
|
| 112 |
-
|
| 113 |
-
# 4. Compile Prompt (System + Profile + Guardrails)
|
| 114 |
-
system_prompt = f"{version.system_prompt_text}\n\n"
|
| 115 |
-
system_prompt += f"BUSINESS PROFILE:\n{json.dumps(version.business_profile_json, indent=2)}\n\n"
|
| 116 |
-
system_prompt += f"GUARDRAILS:\n{json.dumps(version.guardrails_json, indent=2)}"
|
| 117 |
-
|
| 118 |
-
# 5. Fetch History (last 10)
|
| 119 |
result = await db.execute(
|
| 120 |
select(Message)
|
| 121 |
.where(Message.conversation_id == session_id)
|
|
@@ -124,21 +91,21 @@ async def send_test_message(
|
|
| 124 |
)
|
| 125 |
history_objs = result.scalars().all()
|
| 126 |
history_objs.reverse()
|
| 127 |
-
|
| 128 |
history = [
|
| 129 |
{"role": "user" if m.direction == "inbound" else "assistant", "content": m.content}
|
| 130 |
for m in history_objs
|
| 131 |
]
|
| 132 |
|
| 133 |
-
#
|
| 134 |
reply_text = await ai_provider.generate_chat_reply(
|
| 135 |
-
prompt=
|
| 136 |
history=history,
|
| 137 |
-
temperature=
|
| 138 |
-
max_tokens=
|
| 139 |
)
|
| 140 |
|
| 141 |
-
#
|
| 142 |
assistant_msg = Message(
|
| 143 |
workspace_id=workspace.id,
|
| 144 |
conversation_id=session_id,
|
|
|
|
| 1 |
+
from typing import Any, Dict
|
|
|
|
| 2 |
from uuid import UUID
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
from sqlmodel import select, desc
|
|
|
|
| 6 |
|
| 7 |
from app.api import deps
|
| 8 |
from app.core.db import get_db
|
| 9 |
+
from app.models.models import Workspace, Conversation, Message, Contact
|
|
|
|
|
|
|
|
|
|
| 10 |
from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
|
| 11 |
from app.core.ai import ai_provider
|
| 12 |
from app.core.modules import require_module_enabled, MODULE_RUNTIME_ENGINE
|
| 13 |
from app.services.entitlements import require_entitlement
|
| 14 |
+
from app.services.prompt_compiler import compile_workspace_prompt
|
| 15 |
|
| 16 |
router = APIRouter()
|
| 17 |
|
|
|
|
|
|
|
| 18 |
|
| 19 |
@router.post("/sessions", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_RUNTIME_ENGINE, "write")), Depends(require_entitlement("runtime_engine", increment=True))])
|
| 20 |
async def create_test_session(
|
|
|
|
| 22 |
workspace: Workspace = Depends(deps.get_active_workspace),
|
| 23 |
) -> Any:
|
| 24 |
"""Create a new test conversation session."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
result = await db.execute(
|
| 26 |
select(Contact).where(Contact.workspace_id == workspace.id, Contact.external_id == "test-contact")
|
| 27 |
)
|
|
|
|
| 45 |
await db.refresh(conversation)
|
| 46 |
return wrap_data({"session_id": conversation.id})
|
| 47 |
|
| 48 |
+
|
| 49 |
@router.post("/sessions/{session_id}/messages", response_model=ResponseEnvelope[dict], dependencies=[Depends(require_module_enabled(MODULE_RUNTIME_ENGINE, "write")), Depends(require_entitlement("runtime_engine"))])
|
| 50 |
async def send_test_message(
|
| 51 |
session_id: UUID,
|
| 52 |
+
message_in: Dict[str, str],
|
| 53 |
db: AsyncSession = Depends(get_db),
|
| 54 |
workspace: Workspace = Depends(deps.get_active_workspace),
|
| 55 |
) -> Any:
|
| 56 |
+
"""Send a message and get AI response using unified prompt compiler."""
|
| 57 |
text = message_in.get("text")
|
| 58 |
if not text:
|
| 59 |
return wrap_error("Message text is required")
|
|
|
|
| 77 |
db.add(user_msg)
|
| 78 |
await db.flush()
|
| 79 |
|
| 80 |
+
# 3. Compile workspace prompt (includes knowledge files + qualification)
|
| 81 |
+
compiled = await compile_workspace_prompt(workspace.id, db, include_files=True, include_qualification=True)
|
| 82 |
+
if not compiled.version_id:
|
|
|
|
|
|
|
|
|
|
| 83 |
return wrap_error("No active prompt configuration found for this workspace.")
|
| 84 |
|
| 85 |
+
# 4. Fetch History (last 10)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
result = await db.execute(
|
| 87 |
select(Message)
|
| 88 |
.where(Message.conversation_id == session_id)
|
|
|
|
| 91 |
)
|
| 92 |
history_objs = result.scalars().all()
|
| 93 |
history_objs.reverse()
|
| 94 |
+
|
| 95 |
history = [
|
| 96 |
{"role": "user" if m.direction == "inbound" else "assistant", "content": m.content}
|
| 97 |
for m in history_objs
|
| 98 |
]
|
| 99 |
|
| 100 |
+
# 5. Generate AI Reply
|
| 101 |
reply_text = await ai_provider.generate_chat_reply(
|
| 102 |
+
prompt=compiled.system_instruction,
|
| 103 |
history=history,
|
| 104 |
+
temperature=compiled.temperature,
|
| 105 |
+
max_tokens=compiled.max_tokens,
|
| 106 |
)
|
| 107 |
|
| 108 |
+
# 6. Store assistant message
|
| 109 |
assistant_msg = Message(
|
| 110 |
workspace_id=workspace.id,
|
| 111 |
conversation_id=session_id,
|
backend/app/core/catalog_registry.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Catalog Registry — Mission 19
|
| 3 |
+
Single source of truth for all enumerated catalogs.
|
| 4 |
+
The catalog router reads from here (for enums/constants)
|
| 5 |
+
and from DB tables (for plans/modules).
|
| 6 |
+
Backend validation logic also imports from here.
|
| 7 |
+
"""
|
| 8 |
+
from typing import Any, Dict, List
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# ── Integration Providers ────────────────────────────────────────────
|
| 12 |
+
|
| 13 |
+
INTEGRATION_PROVIDERS: List[Dict[str, Any]] = [
|
| 14 |
+
{
|
| 15 |
+
"key": "zoho",
|
| 16 |
+
"label": "Zoho CRM",
|
| 17 |
+
"description": "Sync leads and contacts directly into your Zoho CRM pipeline.",
|
| 18 |
+
"icon_hint": "zoho",
|
| 19 |
+
"fields": [
|
| 20 |
+
{"name": "org_id", "label": "Organization ID", "type": "text"},
|
| 21 |
+
{"name": "access_token", "label": "Access Token", "type": "password"},
|
| 22 |
+
],
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"key": "whatsapp",
|
| 26 |
+
"label": "WhatsApp Cloud API",
|
| 27 |
+
"description": "Official API for professional messaging and automation.",
|
| 28 |
+
"icon_hint": "whatsapp",
|
| 29 |
+
"fields": [
|
| 30 |
+
{"name": "phone_number_id", "label": "Phone Number ID", "type": "text"},
|
| 31 |
+
{"name": "access_token", "label": "Access Token", "type": "password"},
|
| 32 |
+
],
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"key": "meta",
|
| 36 |
+
"label": "Meta (Instagram/Messenger)",
|
| 37 |
+
"description": "Automate responses for Instagram DM and Facebook Messenger.",
|
| 38 |
+
"icon_hint": "meta",
|
| 39 |
+
"fields": [
|
| 40 |
+
{"name": "page_id", "label": "Page ID", "type": "text"},
|
| 41 |
+
{"name": "access_token", "label": "Access Token", "type": "password"},
|
| 42 |
+
],
|
| 43 |
+
},
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
VALID_PROVIDERS = {p["key"] for p in INTEGRATION_PROVIDERS}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ── Automation Node Types ────────────────────────────────────────────
|
| 50 |
+
|
| 51 |
+
AUTOMATION_NODE_TYPES: List[Dict[str, Any]] = [
|
| 52 |
+
{
|
| 53 |
+
"key": "AI_REPLY",
|
| 54 |
+
"label": "AI Reply",
|
| 55 |
+
"description": "Generate AI-powered response using workspace prompt config.",
|
| 56 |
+
"icon_hint": "bot",
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"key": "SEND_MESSAGE",
|
| 60 |
+
"label": "Send Message",
|
| 61 |
+
"description": "Send a message via the connected platform.",
|
| 62 |
+
"icon_hint": "send",
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"key": "HUMAN_HANDOVER",
|
| 66 |
+
"label": "Human Handover",
|
| 67 |
+
"description": "Transfer conversation to a human agent.",
|
| 68 |
+
"icon_hint": "user",
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"key": "TAG_CONTACT",
|
| 72 |
+
"label": "Tag Contact",
|
| 73 |
+
"description": "Apply a tag to the contact for segmentation.",
|
| 74 |
+
"icon_hint": "tag",
|
| 75 |
+
},
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
VALID_NODE_TYPES = {n["key"] for n in AUTOMATION_NODE_TYPES}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ── Automation Trigger Types ─────────────────────────────────────────
|
| 82 |
+
|
| 83 |
+
AUTOMATION_TRIGGER_TYPES: List[Dict[str, Any]] = [
|
| 84 |
+
{
|
| 85 |
+
"key": "MESSAGE_INBOUND",
|
| 86 |
+
"label": "Inbound Message",
|
| 87 |
+
"description": "Triggered when a new message is received.",
|
| 88 |
+
"icon_hint": "message",
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"key": "LEAD_AD_SUBMIT",
|
| 92 |
+
"label": "Lead Ad Submission",
|
| 93 |
+
"description": "Triggered when a Meta Lead Ad form is submitted.",
|
| 94 |
+
"icon_hint": "zap",
|
| 95 |
+
},
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
VALID_TRIGGER_TYPES = {t["key"] for t in AUTOMATION_TRIGGER_TYPES}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ── Conversation Statuses ────────────────────────────────────────────
|
| 102 |
+
|
| 103 |
+
CONVERSATION_STATUSES: List[Dict[str, str]] = [
|
| 104 |
+
{
|
| 105 |
+
"key": "bot_active",
|
| 106 |
+
"label": "AI Active",
|
| 107 |
+
"description": "AI bot is handling this conversation.",
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"key": "human_takeover",
|
| 111 |
+
"label": "Human Control",
|
| 112 |
+
"description": "A human agent has taken over.",
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"key": "closed",
|
| 116 |
+
"label": "Closed",
|
| 117 |
+
"description": "Conversation is closed.",
|
| 118 |
+
},
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ── Message Delivery Statuses ────────────────────────────────────────
|
| 123 |
+
|
| 124 |
+
MESSAGE_DELIVERY_STATUSES: List[Dict[str, str]] = [
|
| 125 |
+
{"key": "pending", "label": "Pending", "description": "Message queued for delivery."},
|
| 126 |
+
{"key": "sending", "label": "Sending", "description": "Message is being sent."},
|
| 127 |
+
{"key": "sent", "label": "Sent", "description": "Message delivered successfully."},
|
| 128 |
+
{"key": "failed", "label": "Failed", "description": "Message delivery failed."},
|
| 129 |
+
]
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ── Workspace Roles ──────────────────────────────────────────────────
|
| 133 |
+
|
| 134 |
+
WORKSPACE_ROLES: List[Dict[str, str]] = [
|
| 135 |
+
{"key": "owner", "label": "Owner", "description": "Full control of the workspace."},
|
| 136 |
+
{"key": "member", "label": "Member", "description": "Can view and edit workspace data."},
|
| 137 |
+
{"key": "viewer", "label": "Viewer", "description": "Read-only access to workspace data."},
|
| 138 |
+
]
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ── Agency Roles (admin-roles catalog) ───────────────────────────────
|
| 142 |
+
|
| 143 |
+
AGENCY_ROLES: List[Dict[str, str]] = [
|
| 144 |
+
{
|
| 145 |
+
"key": "agency_owner",
|
| 146 |
+
"label": "Agency Owner",
|
| 147 |
+
"description": "Full control of the agency and all client workspaces.",
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"key": "agency_admin",
|
| 151 |
+
"label": "Agency Admin",
|
| 152 |
+
"description": "Manage agency settings and client workspaces.",
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
"key": "agency_operator",
|
| 156 |
+
"label": "Agency Operator",
|
| 157 |
+
"description": "Operate client workspaces day-to-day.",
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"key": "agency_viewer",
|
| 161 |
+
"label": "Agency Viewer",
|
| 162 |
+
"description": "Read-only access to agency data.",
|
| 163 |
+
},
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ── Module Labels ────────────────────────────────────────────────────
|
| 168 |
+
|
| 169 |
+
MODULE_LABELS: Dict[str, str] = {
|
| 170 |
+
"auth": "Authentication",
|
| 171 |
+
"email_engine": "Email Engine",
|
| 172 |
+
"email_verification": "Email Verification",
|
| 173 |
+
"prompt_studio": "Prompt Studio",
|
| 174 |
+
"knowledge_files": "Knowledge Files",
|
| 175 |
+
"integrations_hub": "Integrations Hub",
|
| 176 |
+
"integrations_connect": "Integration Connect",
|
| 177 |
+
"webhooks_ingestion": "Webhook Ingestion",
|
| 178 |
+
"runtime_engine": "Runtime Engine",
|
| 179 |
+
"dispatch_engine": "Dispatch Engine",
|
| 180 |
+
"inbox": "Inbox",
|
| 181 |
+
"zoho_sync": "Zoho Sync",
|
| 182 |
+
"analytics": "Analytics",
|
| 183 |
+
"automations": "Automations",
|
| 184 |
+
"diagnostics": "Diagnostics",
|
| 185 |
+
"admin_portal": "Admin Portal",
|
| 186 |
+
}
|
backend/app/domain/runtime.py
CHANGED
|
@@ -8,15 +8,14 @@ from sqlmodel import select
|
|
| 8 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
|
| 10 |
from app.core.db import engine
|
|
|
|
| 11 |
from app.models.models import (
|
| 12 |
-
ExecutionInstance,
|
| 13 |
-
ExecutionStatus,
|
| 14 |
-
ExecutionStepLog,
|
| 15 |
-
FlowVersion,
|
| 16 |
Message,
|
| 17 |
-
|
| 18 |
-
PromptVersion,
|
| 19 |
-
Conversation,
|
| 20 |
Contact,
|
| 21 |
ChannelIdentity,
|
| 22 |
ConversationStatus,
|
|
@@ -283,49 +282,45 @@ async def handle_zoho_upsert(session: AsyncSession, instance: ExecutionInstance,
|
|
| 283 |
return {"zoho_lead_id": zoho_id, "action": action}
|
| 284 |
|
| 285 |
async def handle_ai_reply(session: AsyncSession, instance: ExecutionInstance, config: Dict[str, Any]) -> Dict[str, Any]:
|
| 286 |
-
"""Generates an AI reply using
|
| 287 |
-
# 1.
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
)
|
| 291 |
-
|
| 292 |
-
if not prompt_config or not prompt_config.current_version_id:
|
| 293 |
raise Exception("No active PromptConfig found for workspace")
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
# 2. Fetch History (Last 15 messages)
|
| 298 |
-
# Find relevant conversation
|
| 299 |
conv_result = await session.execute(
|
| 300 |
select(Conversation).where(Conversation.contact_id == instance.contact_id)
|
| 301 |
)
|
| 302 |
conversation = conv_result.scalars().first()
|
| 303 |
if not conversation:
|
| 304 |
raise Exception("Conversation not found for contact")
|
| 305 |
-
|
| 306 |
msg_result = await session.execute(
|
| 307 |
select(Message).where(Message.conversation_id == conversation.id).order_by(Message.created_at.desc()).limit(15)
|
| 308 |
)
|
| 309 |
-
history = msg_result.scalars().all()
|
| 310 |
-
# history is a sequence, need to list it to reverse? sqlmodel returns Sequence.
|
| 311 |
-
# Actually scalars().all() returns a list.
|
| 312 |
-
history = list(history)
|
| 313 |
history.reverse()
|
| 314 |
-
|
| 315 |
messages = []
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
for msg in history:
|
| 320 |
role = "user" if msg.direction == "inbound" else "assistant"
|
| 321 |
messages.append({"role": role, "content": msg.content})
|
| 322 |
-
|
| 323 |
# 3. Generate Reply (workspace settings as fallback for model defaults)
|
| 324 |
from app.core.ai import ai_provider
|
| 325 |
from app.services.settings_service import get_workspace_ai_settings
|
| 326 |
ws_ai = await get_workspace_ai_settings(instance.workspace_id, session)
|
| 327 |
-
effective_temp =
|
| 328 |
-
effective_max_tokens =
|
| 329 |
reply_text = await ai_provider.generate_response(
|
| 330 |
messages=messages,
|
| 331 |
temperature=effective_temp,
|
|
|
|
| 8 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
|
| 10 |
from app.core.db import engine
|
| 11 |
+
from app.services.prompt_compiler import compile_workspace_prompt
|
| 12 |
from app.models.models import (
|
| 13 |
+
ExecutionInstance,
|
| 14 |
+
ExecutionStatus,
|
| 15 |
+
ExecutionStepLog,
|
| 16 |
+
FlowVersion,
|
| 17 |
Message,
|
| 18 |
+
Conversation,
|
|
|
|
|
|
|
| 19 |
Contact,
|
| 20 |
ChannelIdentity,
|
| 21 |
ConversationStatus,
|
|
|
|
| 282 |
return {"zoho_lead_id": zoho_id, "action": action}
|
| 283 |
|
| 284 |
async def handle_ai_reply(session: AsyncSession, instance: ExecutionInstance, config: Dict[str, Any]) -> Dict[str, Any]:
|
| 285 |
+
"""Generates an AI reply using unified prompt compiler + conversation history."""
|
| 286 |
+
# 1. Compile workspace prompt (includes knowledge files, qualification, step config)
|
| 287 |
+
compiled = await compile_workspace_prompt(
|
| 288 |
+
instance.workspace_id,
|
| 289 |
+
session,
|
| 290 |
+
include_files=True,
|
| 291 |
+
include_qualification=True,
|
| 292 |
+
step_config=config,
|
| 293 |
)
|
| 294 |
+
if not compiled.version_id:
|
|
|
|
| 295 |
raise Exception("No active PromptConfig found for workspace")
|
| 296 |
+
|
| 297 |
+
# 2. Fetch conversation and history (last 15 messages)
|
|
|
|
|
|
|
|
|
|
| 298 |
conv_result = await session.execute(
|
| 299 |
select(Conversation).where(Conversation.contact_id == instance.contact_id)
|
| 300 |
)
|
| 301 |
conversation = conv_result.scalars().first()
|
| 302 |
if not conversation:
|
| 303 |
raise Exception("Conversation not found for contact")
|
| 304 |
+
|
| 305 |
msg_result = await session.execute(
|
| 306 |
select(Message).where(Message.conversation_id == conversation.id).order_by(Message.created_at.desc()).limit(15)
|
| 307 |
)
|
| 308 |
+
history = list(msg_result.scalars().all())
|
|
|
|
|
|
|
|
|
|
| 309 |
history.reverse()
|
| 310 |
+
|
| 311 |
messages = []
|
| 312 |
+
messages.append({"role": "system", "content": compiled.system_instruction})
|
| 313 |
+
|
|
|
|
| 314 |
for msg in history:
|
| 315 |
role = "user" if msg.direction == "inbound" else "assistant"
|
| 316 |
messages.append({"role": role, "content": msg.content})
|
| 317 |
+
|
| 318 |
# 3. Generate Reply (workspace settings as fallback for model defaults)
|
| 319 |
from app.core.ai import ai_provider
|
| 320 |
from app.services.settings_service import get_workspace_ai_settings
|
| 321 |
ws_ai = await get_workspace_ai_settings(instance.workspace_id, session)
|
| 322 |
+
effective_temp = compiled.temperature if compiled.temperature != 0.7 else ws_ai.get("temperature", 0.7)
|
| 323 |
+
effective_max_tokens = compiled.max_tokens if compiled.max_tokens != 1000 else ws_ai.get("max_tokens", 2048)
|
| 324 |
reply_text = await ai_provider.generate_response(
|
| 325 |
messages=messages,
|
| 326 |
temperature=effective_temp,
|
backend/app/models/models.py
CHANGED
|
@@ -335,6 +335,20 @@ class WorkspaceKnowledgeFile(WorkspaceScopedModel, table=True):
|
|
| 335 |
storage_path: str
|
| 336 |
extracted_text: Optional[str] = Field(default=None)
|
| 337 |
notes: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
|
| 339 |
class PasswordResetToken(BaseIDModel, table=True):
|
| 340 |
user_id: UUID = Field(foreign_key="user.id", index=True)
|
|
|
|
| 335 |
storage_path: str
|
| 336 |
extracted_text: Optional[str] = Field(default=None)
|
| 337 |
notes: Optional[str] = None
|
| 338 |
+
sha256_hash: Optional[str] = Field(default=None, index=True)
|
| 339 |
+
status: str = Field(default="READY") # READY | FAILED
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
class QualificationConfig(WorkspaceScopedModel, table=True):
|
| 343 |
+
"""Workspace-scoped lead qualification configuration."""
|
| 344 |
+
version: int = Field(default=1)
|
| 345 |
+
qualification_questions: List[Dict[str, Any]] = Field(
|
| 346 |
+
default_factory=list, sa_column=Column(JSON)
|
| 347 |
+
)
|
| 348 |
+
qualification_statuses: List[Dict[str, Any]] = Field(
|
| 349 |
+
default_factory=list, sa_column=Column(JSON)
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
|
| 353 |
class PasswordResetToken(BaseIDModel, table=True):
|
| 354 |
user_id: UUID = Field(foreign_key="user.id", index=True)
|
backend/app/services/prompt_compiler.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Prompt Compiler Service — Mission 20
|
| 3 |
+
Single source of truth for workspace AI context compilation.
|
| 4 |
+
Used by Test Chat AND Runtime AI_REPLY.
|
| 5 |
+
"""
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
from uuid import UUID
|
| 11 |
+
|
| 12 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 13 |
+
from sqlmodel import select
|
| 14 |
+
|
| 15 |
+
from app.models.models import (
|
| 16 |
+
PromptConfig,
|
| 17 |
+
PromptVersion,
|
| 18 |
+
WorkspaceKnowledgeFile,
|
| 19 |
+
QualificationConfig,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
# Stable section separators (never change — part of the prompt contract)
|
| 25 |
+
SEP_BUSINESS_PROFILE = "=== BUSINESS PROFILE ==="
|
| 26 |
+
SEP_GUARDRAILS = "=== GUARDRAILS ==="
|
| 27 |
+
SEP_KNOWLEDGE_BASE = "=== KNOWLEDGE BASE (FILES) ==="
|
| 28 |
+
SEP_QUALIFICATION = "=== LEAD QUALIFICATION ==="
|
| 29 |
+
SEP_STEP_GOAL = "=== STEP GOAL ==="
|
| 30 |
+
SEP_TASKS = "=== TASKS ==="
|
| 31 |
+
SEP_STEP_INSTRUCTIONS = "=== STEP INSTRUCTIONS ==="
|
| 32 |
+
|
| 33 |
+
# Max chars per knowledge file excerpt
|
| 34 |
+
MAX_CHARS_PER_FILE = 4000
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class CompiledPrompt:
|
| 39 |
+
"""Immutable result of a compilation pass."""
|
| 40 |
+
system_instruction: str
|
| 41 |
+
temperature: float
|
| 42 |
+
max_tokens: int
|
| 43 |
+
version_id: Optional[UUID] = None
|
| 44 |
+
version_number: Optional[int] = None
|
| 45 |
+
knowledge_file_count: int = 0
|
| 46 |
+
qualification_question_count: int = 0
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def compile_workspace_prompt(
|
| 50 |
+
workspace_id: UUID,
|
| 51 |
+
db: AsyncSession,
|
| 52 |
+
*,
|
| 53 |
+
include_files: bool = True,
|
| 54 |
+
include_qualification: bool = True,
|
| 55 |
+
step_config: Optional[Dict[str, Any]] = None,
|
| 56 |
+
) -> CompiledPrompt:
|
| 57 |
+
"""
|
| 58 |
+
Assemble the full system instruction for a workspace.
|
| 59 |
+
|
| 60 |
+
Parameters
|
| 61 |
+
----------
|
| 62 |
+
workspace_id : UUID
|
| 63 |
+
The workspace to compile for.
|
| 64 |
+
db : AsyncSession
|
| 65 |
+
Active database session.
|
| 66 |
+
include_files : bool
|
| 67 |
+
Whether to include knowledge file text. Default True.
|
| 68 |
+
include_qualification : bool
|
| 69 |
+
Whether to include qualification config section. Default True.
|
| 70 |
+
step_config : Optional[Dict]
|
| 71 |
+
If provided, appends step-level goal/tasks/extra_instructions
|
| 72 |
+
(used by runtime AI_REPLY for automation steps).
|
| 73 |
+
|
| 74 |
+
Returns
|
| 75 |
+
-------
|
| 76 |
+
CompiledPrompt
|
| 77 |
+
Dataclass with system_instruction, temperature, max_tokens, metadata.
|
| 78 |
+
"""
|
| 79 |
+
# 1. Fetch active PromptVersion
|
| 80 |
+
config_result = await db.execute(
|
| 81 |
+
select(PromptConfig).where(PromptConfig.workspace_id == workspace_id)
|
| 82 |
+
)
|
| 83 |
+
config = config_result.scalars().first()
|
| 84 |
+
if not config or not config.current_version_id:
|
| 85 |
+
return CompiledPrompt(
|
| 86 |
+
system_instruction="You are a helpful assistant.",
|
| 87 |
+
temperature=0.7,
|
| 88 |
+
max_tokens=1000,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
version = await db.get(PromptVersion, config.current_version_id)
|
| 92 |
+
if not version:
|
| 93 |
+
return CompiledPrompt(
|
| 94 |
+
system_instruction="You are a helpful assistant.",
|
| 95 |
+
temperature=0.7,
|
| 96 |
+
max_tokens=1000,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
# 2. Build sections
|
| 100 |
+
sections: List[str] = []
|
| 101 |
+
|
| 102 |
+
# Core compiled instruction (from structured form data)
|
| 103 |
+
if version.system_prompt_text:
|
| 104 |
+
sections.append(version.system_prompt_text)
|
| 105 |
+
|
| 106 |
+
# Business profile
|
| 107 |
+
if version.business_profile_json:
|
| 108 |
+
sections.append(f"\n{SEP_BUSINESS_PROFILE}")
|
| 109 |
+
sections.append(json.dumps(version.business_profile_json, indent=2))
|
| 110 |
+
|
| 111 |
+
# Guardrails
|
| 112 |
+
if version.guardrails_json:
|
| 113 |
+
sections.append(f"\n{SEP_GUARDRAILS}")
|
| 114 |
+
sections.append(json.dumps(version.guardrails_json, indent=2))
|
| 115 |
+
|
| 116 |
+
# 3. Knowledge files
|
| 117 |
+
kb_count = 0
|
| 118 |
+
if include_files:
|
| 119 |
+
files_result = await db.execute(
|
| 120 |
+
select(WorkspaceKnowledgeFile).where(
|
| 121 |
+
WorkspaceKnowledgeFile.workspace_id == workspace_id,
|
| 122 |
+
WorkspaceKnowledgeFile.extracted_text.isnot(None),
|
| 123 |
+
WorkspaceKnowledgeFile.status == "READY",
|
| 124 |
+
)
|
| 125 |
+
)
|
| 126 |
+
files = files_result.scalars().all()
|
| 127 |
+
if files:
|
| 128 |
+
sections.append(f"\n{SEP_KNOWLEDGE_BASE}")
|
| 129 |
+
for f in files:
|
| 130 |
+
text = f.extracted_text
|
| 131 |
+
if len(text) > MAX_CHARS_PER_FILE:
|
| 132 |
+
text = text[:MAX_CHARS_PER_FILE] + "\n[... truncated]"
|
| 133 |
+
sections.append(f"--- {f.filename} ---")
|
| 134 |
+
sections.append(text)
|
| 135 |
+
kb_count = len(files)
|
| 136 |
+
|
| 137 |
+
# 4. Qualification config
|
| 138 |
+
qual_count = 0
|
| 139 |
+
if include_qualification:
|
| 140 |
+
qual_result = await db.execute(
|
| 141 |
+
select(QualificationConfig).where(
|
| 142 |
+
QualificationConfig.workspace_id == workspace_id
|
| 143 |
+
)
|
| 144 |
+
)
|
| 145 |
+
qual = qual_result.scalars().first()
|
| 146 |
+
if qual and qual.qualification_questions:
|
| 147 |
+
enabled_questions = [
|
| 148 |
+
q for q in qual.qualification_questions if q.get("enabled", True)
|
| 149 |
+
]
|
| 150 |
+
if enabled_questions:
|
| 151 |
+
sections.append(f"\n{SEP_QUALIFICATION}")
|
| 152 |
+
sections.append(
|
| 153 |
+
"You must collect the following information from the lead:"
|
| 154 |
+
)
|
| 155 |
+
for q in sorted(enabled_questions, key=lambda x: x.get("order", 0)):
|
| 156 |
+
sections.append(f"- {q['label']}")
|
| 157 |
+
qual_count = len(enabled_questions)
|
| 158 |
+
|
| 159 |
+
# 5. Step-level overrides (for automation AI_REPLY nodes)
|
| 160 |
+
if step_config:
|
| 161 |
+
goal = step_config.get("goal")
|
| 162 |
+
tasks = step_config.get("tasks", [])
|
| 163 |
+
extra = step_config.get("extra_instructions")
|
| 164 |
+
|
| 165 |
+
if goal:
|
| 166 |
+
sections.append(f"\n{SEP_STEP_GOAL}")
|
| 167 |
+
sections.append(goal)
|
| 168 |
+
|
| 169 |
+
if tasks and any(t.strip() for t in tasks if isinstance(t, str)):
|
| 170 |
+
sections.append(f"\n{SEP_TASKS}")
|
| 171 |
+
for t in tasks:
|
| 172 |
+
if isinstance(t, str) and t.strip():
|
| 173 |
+
sections.append(f"- {t.strip()}")
|
| 174 |
+
|
| 175 |
+
if extra:
|
| 176 |
+
sections.append(f"\n{SEP_STEP_INSTRUCTIONS}")
|
| 177 |
+
sections.append(extra)
|
| 178 |
+
|
| 179 |
+
return CompiledPrompt(
|
| 180 |
+
system_instruction="\n".join(sections),
|
| 181 |
+
temperature=version.temperature,
|
| 182 |
+
max_tokens=version.max_tokens_per_execution,
|
| 183 |
+
version_id=version.id,
|
| 184 |
+
version_number=version.version_number,
|
| 185 |
+
knowledge_file_count=kb_count,
|
| 186 |
+
qualification_question_count=qual_count,
|
| 187 |
+
)
|
backend/main.py
CHANGED
|
@@ -16,6 +16,8 @@ from app.api.v1.agency import router as agency_router
|
|
| 16 |
from app.api.v1.settings import router as settings_router
|
| 17 |
from app.api.v1.audit_logs import router as audit_logs_router
|
| 18 |
from app.api.v1.support_timeline import router as support_timeline_router
|
|
|
|
|
|
|
| 19 |
from fastapi import HTTPException
|
| 20 |
import uuid
|
| 21 |
import logging
|
|
@@ -76,6 +78,7 @@ async def maintenance_mode_guard(request: Request, call_next):
|
|
| 76 |
f"{settings.API_V1_STR}/health",
|
| 77 |
f"{settings.API_V1_STR}/admin",
|
| 78 |
f"{settings.API_V1_STR}/admin_auth",
|
|
|
|
| 79 |
"/docs",
|
| 80 |
"/openapi.json",
|
| 81 |
)
|
|
@@ -154,6 +157,8 @@ app.include_router(agency_router, prefix=f"{settings.API_V1_STR}/agency", tags=[
|
|
| 154 |
app.include_router(settings_router, prefix=f"{settings.API_V1_STR}/settings/workspace", tags=["settings"])
|
| 155 |
app.include_router(audit_logs_router, prefix=f"{settings.API_V1_STR}/audit-logs", tags=["audit-logs"])
|
| 156 |
app.include_router(support_timeline_router, prefix=f"{settings.API_V1_STR}", tags=["support-timeline"])
|
|
|
|
|
|
|
| 157 |
|
| 158 |
@app.get("/")
|
| 159 |
async def root():
|
|
|
|
| 16 |
from app.api.v1.settings import router as settings_router
|
| 17 |
from app.api.v1.audit_logs import router as audit_logs_router
|
| 18 |
from app.api.v1.support_timeline import router as support_timeline_router
|
| 19 |
+
from app.api.v1.catalog import router as catalog_router
|
| 20 |
+
from app.api.v1.qualification import router as qualification_router
|
| 21 |
from fastapi import HTTPException
|
| 22 |
import uuid
|
| 23 |
import logging
|
|
|
|
| 78 |
f"{settings.API_V1_STR}/health",
|
| 79 |
f"{settings.API_V1_STR}/admin",
|
| 80 |
f"{settings.API_V1_STR}/admin_auth",
|
| 81 |
+
f"{settings.API_V1_STR}/catalog",
|
| 82 |
"/docs",
|
| 83 |
"/openapi.json",
|
| 84 |
)
|
|
|
|
| 157 |
app.include_router(settings_router, prefix=f"{settings.API_V1_STR}/settings/workspace", tags=["settings"])
|
| 158 |
app.include_router(audit_logs_router, prefix=f"{settings.API_V1_STR}/audit-logs", tags=["audit-logs"])
|
| 159 |
app.include_router(support_timeline_router, prefix=f"{settings.API_V1_STR}", tags=["support-timeline"])
|
| 160 |
+
app.include_router(catalog_router, prefix=f"{settings.API_V1_STR}/catalog", tags=["catalog"])
|
| 161 |
+
app.include_router(qualification_router, prefix=f"{settings.API_V1_STR}/qualification-config", tags=["qualification"])
|
| 162 |
|
| 163 |
@app.get("/")
|
| 164 |
async def root():
|
backend/tests/test_automation.py
CHANGED
|
@@ -20,7 +20,7 @@ async def test_automation_publish(async_client: AsyncClient):
|
|
| 20 |
"name": "Test Flow",
|
| 21 |
"description": "Integration test flow",
|
| 22 |
"steps": [],
|
| 23 |
-
"trigger": {"type": "
|
| 24 |
"publish": False
|
| 25 |
}
|
| 26 |
start_res = await async_client.post("/api/v1/automations/from-builder", json=flow_payload, headers=headers)
|
|
|
|
| 20 |
"name": "Test Flow",
|
| 21 |
"description": "Integration test flow",
|
| 22 |
"steps": [],
|
| 23 |
+
"trigger": {"type": "MESSAGE_INBOUND", "platform": "WHATSAPP", "keywords": []},
|
| 24 |
"publish": False
|
| 25 |
}
|
| 26 |
start_res = await async_client.post("/api/v1/automations/from-builder", json=flow_payload, headers=headers)
|
backend/tests/test_catalog.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Mission 19: Catalog API Tests
|
| 3 |
+
Tests all 10 catalog endpoints return correct ResponseEnvelope structure.
|
| 4 |
+
"""
|
| 5 |
+
import pytest
|
| 6 |
+
import pytest_asyncio
|
| 7 |
+
from httpx import AsyncClient
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
|
| 10 |
+
from app.models.models import Plan, PlanEntitlement, SystemModuleConfig
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# ── Seed helpers ─────────────────────────────────────────────────────
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def _seed_plan(db: AsyncSession):
|
| 17 |
+
plan = Plan(
|
| 18 |
+
name="test_catalog_plan",
|
| 19 |
+
display_name="Test Catalog Plan",
|
| 20 |
+
sort_order=0,
|
| 21 |
+
is_active=True,
|
| 22 |
+
description="A plan for catalog tests",
|
| 23 |
+
)
|
| 24 |
+
db.add(plan)
|
| 25 |
+
await db.flush()
|
| 26 |
+
ent = PlanEntitlement(plan_id=plan.id, module_key="analytics", hard_limit=100)
|
| 27 |
+
db.add(ent)
|
| 28 |
+
await db.flush()
|
| 29 |
+
return plan
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def _seed_module(db: AsyncSession):
|
| 33 |
+
mod = SystemModuleConfig(
|
| 34 |
+
module_name="test_catalog_module",
|
| 35 |
+
is_enabled=True,
|
| 36 |
+
)
|
| 37 |
+
db.add(mod)
|
| 38 |
+
await db.flush()
|
| 39 |
+
return mod
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ── DB-backed endpoints ──────────────────────────────────────────────
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@pytest.mark.asyncio
|
| 46 |
+
async def test_catalog_plans(async_client: AsyncClient, db_session: AsyncSession):
|
| 47 |
+
await _seed_plan(db_session)
|
| 48 |
+
response = await async_client.get("/api/v1/catalog/plans")
|
| 49 |
+
assert response.status_code == 200
|
| 50 |
+
data = response.json()
|
| 51 |
+
assert data["success"] is True
|
| 52 |
+
assert isinstance(data["data"], list)
|
| 53 |
+
assert len(data["data"]) >= 1
|
| 54 |
+
plan = data["data"][0]
|
| 55 |
+
assert "name" in plan
|
| 56 |
+
assert "display_name" in plan
|
| 57 |
+
assert "entitlements" in plan
|
| 58 |
+
assert "Cache-Control" in response.headers
|
| 59 |
+
assert "max-age=60" in response.headers["Cache-Control"]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@pytest.mark.asyncio
|
| 63 |
+
async def test_catalog_tiers_is_plans_alias(
|
| 64 |
+
async_client: AsyncClient, db_session: AsyncSession
|
| 65 |
+
):
|
| 66 |
+
await _seed_plan(db_session)
|
| 67 |
+
response = await async_client.get("/api/v1/catalog/tiers")
|
| 68 |
+
assert response.status_code == 200
|
| 69 |
+
data = response.json()
|
| 70 |
+
assert data["success"] is True
|
| 71 |
+
assert isinstance(data["data"], list)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@pytest.mark.asyncio
|
| 75 |
+
async def test_catalog_modules(async_client: AsyncClient, db_session: AsyncSession):
|
| 76 |
+
await _seed_module(db_session)
|
| 77 |
+
response = await async_client.get("/api/v1/catalog/modules")
|
| 78 |
+
assert response.status_code == 200
|
| 79 |
+
data = response.json()
|
| 80 |
+
assert data["success"] is True
|
| 81 |
+
assert isinstance(data["data"], list)
|
| 82 |
+
assert any(m["key"] == "test_catalog_module" for m in data["data"])
|
| 83 |
+
for m in data["data"]:
|
| 84 |
+
assert "key" in m
|
| 85 |
+
assert "label" in m
|
| 86 |
+
assert "is_enabled" in m
|
| 87 |
+
assert "Cache-Control" in response.headers
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ── Static (registry-backed) endpoints ───────────────────────────────
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
STATIC_ENDPOINTS = [
|
| 94 |
+
("workspace-roles", ["owner", "member", "viewer"]),
|
| 95 |
+
("admin-roles", ["agency_owner", "agency_admin", "agency_operator", "agency_viewer"]),
|
| 96 |
+
("integration-providers", ["zoho", "whatsapp", "meta"]),
|
| 97 |
+
(
|
| 98 |
+
"automation-node-types",
|
| 99 |
+
["AI_REPLY", "SEND_MESSAGE", "HUMAN_HANDOVER", "TAG_CONTACT"],
|
| 100 |
+
),
|
| 101 |
+
("automation-trigger-types", ["MESSAGE_INBOUND", "LEAD_AD_SUBMIT"]),
|
| 102 |
+
("conversation-statuses", ["bot_active", "human_takeover", "closed"]),
|
| 103 |
+
("message-delivery-statuses", ["pending", "sending", "sent", "failed"]),
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@pytest.mark.asyncio
|
| 108 |
+
@pytest.mark.parametrize("endpoint,expected_keys", STATIC_ENDPOINTS)
|
| 109 |
+
async def test_catalog_static_endpoints(
|
| 110 |
+
async_client: AsyncClient, endpoint: str, expected_keys: list
|
| 111 |
+
):
|
| 112 |
+
response = await async_client.get(f"/api/v1/catalog/{endpoint}")
|
| 113 |
+
assert response.status_code == 200
|
| 114 |
+
data = response.json()
|
| 115 |
+
assert data["success"] is True
|
| 116 |
+
assert isinstance(data["data"], list)
|
| 117 |
+
actual_keys = [item["key"] for item in data["data"]]
|
| 118 |
+
for key in expected_keys:
|
| 119 |
+
assert key in actual_keys, f"Expected key '{key}' in {endpoint} catalog"
|
| 120 |
+
# Every item must have key + label
|
| 121 |
+
for item in data["data"]:
|
| 122 |
+
assert "key" in item
|
| 123 |
+
assert "label" in item
|
| 124 |
+
# Cache header
|
| 125 |
+
assert "Cache-Control" in response.headers
|
| 126 |
+
assert "max-age=60" in response.headers["Cache-Control"]
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@pytest.mark.asyncio
|
| 130 |
+
async def test_catalog_unknown_endpoint_404(async_client: AsyncClient):
|
| 131 |
+
response = await async_client.get("/api/v1/catalog/nonexistent")
|
| 132 |
+
assert response.status_code in (404, 405)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@pytest.mark.asyncio
|
| 136 |
+
async def test_catalog_integration_providers_have_fields(async_client: AsyncClient):
|
| 137 |
+
response = await async_client.get("/api/v1/catalog/integration-providers")
|
| 138 |
+
data = response.json()
|
| 139 |
+
for provider in data["data"]:
|
| 140 |
+
assert "fields" in provider
|
| 141 |
+
assert isinstance(provider["fields"], list)
|
| 142 |
+
for field in provider["fields"]:
|
| 143 |
+
assert "name" in field
|
| 144 |
+
assert "label" in field
|
| 145 |
+
assert "type" in field
|
backend/tests/test_knowledge.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Knowledge Files API — Mission 20."""
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import pytest
|
| 5 |
+
import pytest_asyncio
|
| 6 |
+
from httpx import AsyncClient
|
| 7 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def get_auth_headers(client: AsyncClient, email: str = "kb@test.com") -> dict:
|
| 11 |
+
pwd = "password123"
|
| 12 |
+
await client.post(
|
| 13 |
+
"/api/v1/auth/signup",
|
| 14 |
+
json={"email": email, "password": pwd, "full_name": "KB Tester"},
|
| 15 |
+
)
|
| 16 |
+
login_res = await client.post(
|
| 17 |
+
"/api/v1/auth/login",
|
| 18 |
+
data={"username": email, "password": pwd},
|
| 19 |
+
headers={"content-type": "application/x-www-form-urlencoded"},
|
| 20 |
+
)
|
| 21 |
+
token = login_res.json()["data"]["access_token"]
|
| 22 |
+
ws_res = await client.get(
|
| 23 |
+
"/api/v1/workspaces", headers={"Authorization": f"Bearer {token}"}
|
| 24 |
+
)
|
| 25 |
+
ws_id = ws_res.json()["data"][0]["id"]
|
| 26 |
+
return {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@pytest.mark.asyncio
|
| 30 |
+
async def test_upload_and_list_knowledge_file(async_client: AsyncClient):
|
| 31 |
+
"""Upload a .txt file and verify it appears in the list."""
|
| 32 |
+
headers = await get_auth_headers(async_client, "kb_upload@test.com")
|
| 33 |
+
|
| 34 |
+
file_content = b"Our pricing starts at $99/month for the basic plan."
|
| 35 |
+
res = await async_client.post(
|
| 36 |
+
"/api/v1/knowledge/files",
|
| 37 |
+
files={"file": ("pricing.txt", io.BytesIO(file_content), "text/plain")},
|
| 38 |
+
headers=headers,
|
| 39 |
+
)
|
| 40 |
+
assert res.status_code == 200
|
| 41 |
+
body = res.json()
|
| 42 |
+
assert body["success"] is True
|
| 43 |
+
assert body["data"]["extracted"] is True
|
| 44 |
+
assert body["data"]["status"] == "READY"
|
| 45 |
+
|
| 46 |
+
# List files
|
| 47 |
+
list_res = await async_client.get("/api/v1/knowledge/files", headers=headers)
|
| 48 |
+
assert list_res.status_code == 200
|
| 49 |
+
files = list_res.json()["data"]
|
| 50 |
+
assert len(files) >= 1
|
| 51 |
+
found = [f for f in files if f["filename"] == "pricing.txt"]
|
| 52 |
+
assert len(found) == 1
|
| 53 |
+
assert found[0]["status"] == "READY"
|
| 54 |
+
assert found[0]["extracted"] is True
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@pytest.mark.asyncio
|
| 58 |
+
async def test_sha256_dedupe_rejects_duplicate(async_client: AsyncClient):
|
| 59 |
+
"""Uploading the same content twice should return an error."""
|
| 60 |
+
headers = await get_auth_headers(async_client, "kb_dedupe@test.com")
|
| 61 |
+
|
| 62 |
+
file_content = b"Unique content for dedup test 12345"
|
| 63 |
+
# First upload
|
| 64 |
+
res1 = await async_client.post(
|
| 65 |
+
"/api/v1/knowledge/files",
|
| 66 |
+
files={"file": ("doc1.txt", io.BytesIO(file_content), "text/plain")},
|
| 67 |
+
headers=headers,
|
| 68 |
+
)
|
| 69 |
+
assert res1.status_code == 200
|
| 70 |
+
assert res1.json()["success"] is True
|
| 71 |
+
|
| 72 |
+
# Second upload — same content, different filename
|
| 73 |
+
res2 = await async_client.post(
|
| 74 |
+
"/api/v1/knowledge/files",
|
| 75 |
+
files={"file": ("doc2.txt", io.BytesIO(file_content), "text/plain")},
|
| 76 |
+
headers=headers,
|
| 77 |
+
)
|
| 78 |
+
assert res2.status_code == 200
|
| 79 |
+
body = res2.json()
|
| 80 |
+
assert body["success"] is False
|
| 81 |
+
assert "Duplicate" in body["error"]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@pytest.mark.asyncio
|
| 85 |
+
async def test_delete_knowledge_file(async_client: AsyncClient):
|
| 86 |
+
"""Upload then delete a file and confirm it's gone."""
|
| 87 |
+
headers = await get_auth_headers(async_client, "kb_delete@test.com")
|
| 88 |
+
|
| 89 |
+
file_content = b"File to be deleted"
|
| 90 |
+
upload_res = await async_client.post(
|
| 91 |
+
"/api/v1/knowledge/files",
|
| 92 |
+
files={"file": ("deleteme.txt", io.BytesIO(file_content), "text/plain")},
|
| 93 |
+
headers=headers,
|
| 94 |
+
)
|
| 95 |
+
file_id = upload_res.json()["data"]["id"]
|
| 96 |
+
|
| 97 |
+
# Delete
|
| 98 |
+
del_res = await async_client.delete(
|
| 99 |
+
f"/api/v1/knowledge/files/{file_id}", headers=headers
|
| 100 |
+
)
|
| 101 |
+
assert del_res.status_code == 200
|
| 102 |
+
assert del_res.json()["data"]["deleted"] is True
|
| 103 |
+
|
| 104 |
+
# Verify gone from list
|
| 105 |
+
list_res = await async_client.get("/api/v1/knowledge/files", headers=headers)
|
| 106 |
+
ids = [f["id"] for f in list_res.json()["data"]]
|
| 107 |
+
assert file_id not in ids
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@pytest.mark.asyncio
|
| 111 |
+
async def test_download_knowledge_file(async_client: AsyncClient):
|
| 112 |
+
"""Upload a file and download it back."""
|
| 113 |
+
headers = await get_auth_headers(async_client, "kb_download@test.com")
|
| 114 |
+
|
| 115 |
+
file_content = b"Download me!"
|
| 116 |
+
upload_res = await async_client.post(
|
| 117 |
+
"/api/v1/knowledge/files",
|
| 118 |
+
files={"file": ("download.txt", io.BytesIO(file_content), "text/plain")},
|
| 119 |
+
headers=headers,
|
| 120 |
+
)
|
| 121 |
+
file_id = upload_res.json()["data"]["id"]
|
| 122 |
+
|
| 123 |
+
# Download
|
| 124 |
+
dl_res = await async_client.get(
|
| 125 |
+
f"/api/v1/knowledge/files/{file_id}/download", headers=headers
|
| 126 |
+
)
|
| 127 |
+
assert dl_res.status_code == 200
|
| 128 |
+
assert dl_res.content == file_content
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@pytest.mark.asyncio
|
| 132 |
+
async def test_upload_unsupported_format_status(async_client: AsyncClient):
|
| 133 |
+
"""Upload a .bin file — should have status READY but no extracted text."""
|
| 134 |
+
headers = await get_auth_headers(async_client, "kb_bin@test.com")
|
| 135 |
+
|
| 136 |
+
res = await async_client.post(
|
| 137 |
+
"/api/v1/knowledge/files",
|
| 138 |
+
files={"file": ("data.bin", io.BytesIO(b"\x00\x01\x02"), "application/octet-stream")},
|
| 139 |
+
headers=headers,
|
| 140 |
+
)
|
| 141 |
+
assert res.status_code == 200
|
| 142 |
+
body = res.json()
|
| 143 |
+
assert body["success"] is True
|
| 144 |
+
# Unsupported format → no extraction, but still READY
|
| 145 |
+
assert body["data"]["extracted"] is False
|
backend/tests/test_prompt_compiler.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Prompt Compiler Service — Mission 20."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
import pytest_asyncio
|
| 5 |
+
from uuid import uuid4
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
|
| 8 |
+
from app.models.models import (
|
| 9 |
+
Workspace,
|
| 10 |
+
PromptConfig,
|
| 11 |
+
PromptVersion,
|
| 12 |
+
WorkspaceKnowledgeFile,
|
| 13 |
+
QualificationConfig,
|
| 14 |
+
)
|
| 15 |
+
from app.services.prompt_compiler import compile_workspace_prompt
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest_asyncio.fixture
|
| 19 |
+
async def compiler_workspace(db_session: AsyncSession):
|
| 20 |
+
"""Create a workspace with prompt config, knowledge files, and qualification config."""
|
| 21 |
+
ws = Workspace(id=uuid4(), name="Compiler Test WS")
|
| 22 |
+
db_session.add(ws)
|
| 23 |
+
await db_session.flush()
|
| 24 |
+
|
| 25 |
+
# Create PromptConfig first (version needs prompt_config_id)
|
| 26 |
+
config = PromptConfig(
|
| 27 |
+
id=uuid4(),
|
| 28 |
+
workspace_id=ws.id,
|
| 29 |
+
current_version_id=None, # Will update after version created
|
| 30 |
+
)
|
| 31 |
+
db_session.add(config)
|
| 32 |
+
await db_session.flush()
|
| 33 |
+
|
| 34 |
+
# Prompt version
|
| 35 |
+
version = PromptVersion(
|
| 36 |
+
id=uuid4(),
|
| 37 |
+
prompt_config_id=config.id,
|
| 38 |
+
version_number=1,
|
| 39 |
+
system_prompt_text="You are a helpful sales assistant.",
|
| 40 |
+
structured_data={},
|
| 41 |
+
compiled_system_instruction="You are a helpful sales assistant.",
|
| 42 |
+
business_profile_json={"company": "Acme Corp", "industry": "Tech"},
|
| 43 |
+
guardrails_json={"never_do": ["make up pricing"]},
|
| 44 |
+
model="gemini-pro",
|
| 45 |
+
temperature=0.8,
|
| 46 |
+
max_tokens_per_execution=500,
|
| 47 |
+
)
|
| 48 |
+
db_session.add(version)
|
| 49 |
+
await db_session.flush()
|
| 50 |
+
|
| 51 |
+
# Update config to point to this version
|
| 52 |
+
config.current_version_id = version.id
|
| 53 |
+
|
| 54 |
+
# Knowledge files
|
| 55 |
+
kf1 = WorkspaceKnowledgeFile(
|
| 56 |
+
id=uuid4(),
|
| 57 |
+
workspace_id=ws.id,
|
| 58 |
+
filename="pricing.txt",
|
| 59 |
+
mime_type="text/plain",
|
| 60 |
+
size_bytes=100,
|
| 61 |
+
storage_path="/tmp/fake.txt",
|
| 62 |
+
extracted_text="Our basic plan costs $99/month.",
|
| 63 |
+
status="READY",
|
| 64 |
+
sha256_hash="abc123",
|
| 65 |
+
)
|
| 66 |
+
kf2 = WorkspaceKnowledgeFile(
|
| 67 |
+
id=uuid4(),
|
| 68 |
+
workspace_id=ws.id,
|
| 69 |
+
filename="faq.txt",
|
| 70 |
+
mime_type="text/plain",
|
| 71 |
+
size_bytes=50,
|
| 72 |
+
storage_path="/tmp/fake2.txt",
|
| 73 |
+
extracted_text="We support WhatsApp and email.",
|
| 74 |
+
status="READY",
|
| 75 |
+
sha256_hash="def456",
|
| 76 |
+
)
|
| 77 |
+
kf_failed = WorkspaceKnowledgeFile(
|
| 78 |
+
id=uuid4(),
|
| 79 |
+
workspace_id=ws.id,
|
| 80 |
+
filename="broken.pdf",
|
| 81 |
+
mime_type="application/pdf",
|
| 82 |
+
size_bytes=200,
|
| 83 |
+
storage_path="/tmp/fake3.pdf",
|
| 84 |
+
extracted_text=None,
|
| 85 |
+
status="FAILED",
|
| 86 |
+
sha256_hash="ghi789",
|
| 87 |
+
)
|
| 88 |
+
db_session.add_all([kf1, kf2, kf_failed])
|
| 89 |
+
|
| 90 |
+
# Qualification config
|
| 91 |
+
qual = QualificationConfig(
|
| 92 |
+
id=uuid4(),
|
| 93 |
+
workspace_id=ws.id,
|
| 94 |
+
version=1,
|
| 95 |
+
qualification_questions=[
|
| 96 |
+
{"label": "Full Name", "enabled": True, "order": 0},
|
| 97 |
+
{"label": "Budget Range", "enabled": True, "order": 1},
|
| 98 |
+
{"label": "Disabled Q", "enabled": False, "order": 2},
|
| 99 |
+
],
|
| 100 |
+
qualification_statuses=[
|
| 101 |
+
{"label": "New", "color": "#94a3b8", "enabled": True},
|
| 102 |
+
],
|
| 103 |
+
)
|
| 104 |
+
db_session.add(qual)
|
| 105 |
+
await db_session.flush()
|
| 106 |
+
|
| 107 |
+
return ws
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@pytest.mark.asyncio
|
| 111 |
+
async def test_compiler_includes_all_sections(db_session: AsyncSession, compiler_workspace):
|
| 112 |
+
"""Compiler output includes prompt text, business profile, guardrails, files, qualification."""
|
| 113 |
+
ws = compiler_workspace
|
| 114 |
+
compiled = await compile_workspace_prompt(ws.id, db_session)
|
| 115 |
+
|
| 116 |
+
si = compiled.system_instruction
|
| 117 |
+
|
| 118 |
+
# Core prompt
|
| 119 |
+
assert "You are a helpful sales assistant." in si
|
| 120 |
+
|
| 121 |
+
# Business profile
|
| 122 |
+
assert "=== BUSINESS PROFILE ===" in si
|
| 123 |
+
assert "Acme Corp" in si
|
| 124 |
+
|
| 125 |
+
# Guardrails
|
| 126 |
+
assert "=== GUARDRAILS ===" in si
|
| 127 |
+
assert "make up pricing" in si
|
| 128 |
+
|
| 129 |
+
# Knowledge files (only READY with extracted_text)
|
| 130 |
+
assert "=== KNOWLEDGE BASE (FILES) ===" in si
|
| 131 |
+
assert "pricing.txt" in si
|
| 132 |
+
assert "$99/month" in si
|
| 133 |
+
assert "faq.txt" in si
|
| 134 |
+
assert "WhatsApp" in si
|
| 135 |
+
# FAILED file should NOT appear
|
| 136 |
+
assert "broken.pdf" not in si
|
| 137 |
+
|
| 138 |
+
# Qualification (only enabled questions)
|
| 139 |
+
assert "=== LEAD QUALIFICATION ===" in si
|
| 140 |
+
assert "Full Name" in si
|
| 141 |
+
assert "Budget Range" in si
|
| 142 |
+
assert "Disabled Q" not in si
|
| 143 |
+
|
| 144 |
+
# Metadata
|
| 145 |
+
assert compiled.knowledge_file_count == 2
|
| 146 |
+
assert compiled.qualification_question_count == 2
|
| 147 |
+
assert compiled.temperature == 0.8
|
| 148 |
+
assert compiled.max_tokens == 500
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@pytest.mark.asyncio
|
| 152 |
+
async def test_compiler_with_step_config(db_session: AsyncSession, compiler_workspace):
|
| 153 |
+
"""Compiler appends step-level config when provided."""
|
| 154 |
+
ws = compiler_workspace
|
| 155 |
+
step_config = {
|
| 156 |
+
"goal": "Schedule a demo call",
|
| 157 |
+
"tasks": ["Ask for preferred date", "Confirm timezone"],
|
| 158 |
+
"extra_instructions": "Be concise and direct.",
|
| 159 |
+
}
|
| 160 |
+
compiled = await compile_workspace_prompt(
|
| 161 |
+
ws.id, db_session, step_config=step_config
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
si = compiled.system_instruction
|
| 165 |
+
assert "=== STEP GOAL ===" in si
|
| 166 |
+
assert "Schedule a demo call" in si
|
| 167 |
+
assert "=== TASKS ===" in si
|
| 168 |
+
assert "Ask for preferred date" in si
|
| 169 |
+
assert "Confirm timezone" in si
|
| 170 |
+
assert "=== STEP INSTRUCTIONS ===" in si
|
| 171 |
+
assert "Be concise and direct." in si
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@pytest.mark.asyncio
|
| 175 |
+
async def test_compiler_without_files(db_session: AsyncSession, compiler_workspace):
|
| 176 |
+
"""With include_files=False, knowledge base section is omitted."""
|
| 177 |
+
ws = compiler_workspace
|
| 178 |
+
compiled = await compile_workspace_prompt(
|
| 179 |
+
ws.id, db_session, include_files=False
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
si = compiled.system_instruction
|
| 183 |
+
assert "=== KNOWLEDGE BASE (FILES) ===" not in si
|
| 184 |
+
assert compiled.knowledge_file_count == 0
|
| 185 |
+
# Other sections still present
|
| 186 |
+
assert "=== BUSINESS PROFILE ===" in si
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@pytest.mark.asyncio
|
| 190 |
+
async def test_compiler_without_qualification(db_session: AsyncSession, compiler_workspace):
|
| 191 |
+
"""With include_qualification=False, qualification section is omitted."""
|
| 192 |
+
ws = compiler_workspace
|
| 193 |
+
compiled = await compile_workspace_prompt(
|
| 194 |
+
ws.id, db_session, include_qualification=False
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
si = compiled.system_instruction
|
| 198 |
+
assert "=== LEAD QUALIFICATION ===" not in si
|
| 199 |
+
assert compiled.qualification_question_count == 0
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@pytest.mark.asyncio
|
| 203 |
+
async def test_compiler_no_config_returns_default(db_session: AsyncSession):
|
| 204 |
+
"""Workspace with no PromptConfig returns a sensible default."""
|
| 205 |
+
ws = Workspace(id=uuid4(), name="Empty WS")
|
| 206 |
+
db_session.add(ws)
|
| 207 |
+
await db_session.flush()
|
| 208 |
+
|
| 209 |
+
compiled = await compile_workspace_prompt(ws.id, db_session)
|
| 210 |
+
assert compiled.system_instruction == "You are a helpful assistant."
|
| 211 |
+
assert compiled.temperature == 0.7
|
| 212 |
+
assert compiled.max_tokens == 1000
|
backend/tests/test_qualification.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Qualification Config API — Mission 20."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
import pytest_asyncio
|
| 5 |
+
from httpx import AsyncClient
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def get_auth_headers(client: AsyncClient, email: str = "qual@test.com") -> dict:
|
| 9 |
+
pwd = "password123"
|
| 10 |
+
await client.post(
|
| 11 |
+
"/api/v1/auth/signup",
|
| 12 |
+
json={"email": email, "password": pwd, "full_name": "Qual Tester"},
|
| 13 |
+
)
|
| 14 |
+
login_res = await client.post(
|
| 15 |
+
"/api/v1/auth/login",
|
| 16 |
+
data={"username": email, "password": pwd},
|
| 17 |
+
headers={"content-type": "application/x-www-form-urlencoded"},
|
| 18 |
+
)
|
| 19 |
+
token = login_res.json()["data"]["access_token"]
|
| 20 |
+
ws_res = await client.get(
|
| 21 |
+
"/api/v1/workspaces", headers={"Authorization": f"Bearer {token}"}
|
| 22 |
+
)
|
| 23 |
+
ws_id = ws_res.json()["data"][0]["id"]
|
| 24 |
+
return {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@pytest.mark.asyncio
|
| 28 |
+
async def test_get_qualification_config_lazy_creates(async_client: AsyncClient):
|
| 29 |
+
"""First GET creates a default config with standard questions and statuses."""
|
| 30 |
+
headers = await get_auth_headers(async_client, "qual_lazy@test.com")
|
| 31 |
+
|
| 32 |
+
res = await async_client.get("/api/v1/qualification-config", headers=headers)
|
| 33 |
+
assert res.status_code == 200
|
| 34 |
+
body = res.json()
|
| 35 |
+
assert body["success"] is True
|
| 36 |
+
|
| 37 |
+
data = body["data"]
|
| 38 |
+
assert data["version"] == 1
|
| 39 |
+
|
| 40 |
+
# Check default questions
|
| 41 |
+
questions = data["qualification_questions"]
|
| 42 |
+
assert len(questions) >= 4 # At least 4 default questions
|
| 43 |
+
labels = [q["label"] for q in questions]
|
| 44 |
+
assert "Full Name" in labels
|
| 45 |
+
assert "Company Email" in labels
|
| 46 |
+
|
| 47 |
+
# Check default statuses
|
| 48 |
+
statuses = data["qualification_statuses"]
|
| 49 |
+
assert len(statuses) >= 3
|
| 50 |
+
status_labels = [s["label"] for s in statuses]
|
| 51 |
+
assert "Qualified" in status_labels
|
| 52 |
+
assert "Unqualified" in status_labels
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@pytest.mark.asyncio
|
| 56 |
+
async def test_update_qualification_config_increments_version(async_client: AsyncClient):
|
| 57 |
+
"""POST updates the config and increments the version number."""
|
| 58 |
+
headers = await get_auth_headers(async_client, "qual_update@test.com")
|
| 59 |
+
|
| 60 |
+
# First, trigger lazy creation
|
| 61 |
+
await async_client.get("/api/v1/qualification-config", headers=headers)
|
| 62 |
+
|
| 63 |
+
# Update with custom questions
|
| 64 |
+
custom_questions = [
|
| 65 |
+
{"label": "Company Size", "enabled": True, "order": 0},
|
| 66 |
+
{"label": "Annual Revenue", "enabled": True, "order": 1},
|
| 67 |
+
]
|
| 68 |
+
custom_statuses = [
|
| 69 |
+
{"label": "Hot Lead", "color": "#ef4444", "enabled": True},
|
| 70 |
+
{"label": "Cold Lead", "color": "#94a3b8", "enabled": True},
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
res = await async_client.post(
|
| 74 |
+
"/api/v1/qualification-config",
|
| 75 |
+
json={
|
| 76 |
+
"qualification_questions": custom_questions,
|
| 77 |
+
"qualification_statuses": custom_statuses,
|
| 78 |
+
},
|
| 79 |
+
headers=headers,
|
| 80 |
+
)
|
| 81 |
+
assert res.status_code == 200
|
| 82 |
+
body = res.json()
|
| 83 |
+
assert body["success"] is True
|
| 84 |
+
assert body["data"]["version"] == 2
|
| 85 |
+
|
| 86 |
+
# GET again to confirm persistence
|
| 87 |
+
get_res = await async_client.get("/api/v1/qualification-config", headers=headers)
|
| 88 |
+
data = get_res.json()["data"]
|
| 89 |
+
assert data["version"] == 2
|
| 90 |
+
assert len(data["qualification_questions"]) == 2
|
| 91 |
+
assert data["qualification_questions"][0]["label"] == "Company Size"
|
| 92 |
+
assert len(data["qualification_statuses"]) == 2
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@pytest.mark.asyncio
|
| 96 |
+
async def test_qualification_idempotent_get(async_client: AsyncClient):
|
| 97 |
+
"""Multiple GETs return the same config without creating duplicates."""
|
| 98 |
+
headers = await get_auth_headers(async_client, "qual_idempotent@test.com")
|
| 99 |
+
|
| 100 |
+
res1 = await async_client.get("/api/v1/qualification-config", headers=headers)
|
| 101 |
+
res2 = await async_client.get("/api/v1/qualification-config", headers=headers)
|
| 102 |
+
|
| 103 |
+
assert res1.json()["data"]["version"] == res2.json()["data"]["version"]
|
| 104 |
+
assert len(res1.json()["data"]["qualification_questions"]) == len(
|
| 105 |
+
res2.json()["data"]["qualification_questions"]
|
| 106 |
+
)
|
docs/missions/mission_19.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Mission 19 — Data Flow Integrity & Catalog Standardization Gate
|
| 2 |
+
|
| 3 |
+
## Summary
|
| 4 |
+
|
| 5 |
+
Mission 19 introduces a backend catalog registry and API layer that serves as the single source of truth for all enum-based reference data in LeadPilot V2. The frontend was swept to replace 11+ hardcoded arrays with a `useCatalog()` hook that fetches from the new `/api/v1/catalog/*` endpoints.
|
| 6 |
+
|
| 7 |
+
**Result:** Adding a new integration provider, automation step type, or status enum now requires only a backend change to `catalog_registry.py` — the frontend picks it up automatically.
|
| 8 |
+
|
| 9 |
+
## Catalog Endpoints
|
| 10 |
+
|
| 11 |
+
| # | Endpoint | Source | Auth |
|
| 12 |
+
|---|---|---|---|
|
| 13 |
+
| 1 | `GET /api/v1/catalog/plans` | DB: `Plan` + `PlanEntitlement` | None |
|
| 14 |
+
| 2 | `GET /api/v1/catalog/tiers` | Alias for /plans | None |
|
| 15 |
+
| 3 | `GET /api/v1/catalog/modules` | DB: `SystemModuleConfig` + registry labels | None |
|
| 16 |
+
| 4 | `GET /api/v1/catalog/workspace-roles` | Registry | None |
|
| 17 |
+
| 5 | `GET /api/v1/catalog/admin-roles` | Registry | None |
|
| 18 |
+
| 6 | `GET /api/v1/catalog/integration-providers` | Registry | None |
|
| 19 |
+
| 7 | `GET /api/v1/catalog/automation-node-types` | Registry | None |
|
| 20 |
+
| 8 | `GET /api/v1/catalog/automation-trigger-types` | Registry | None |
|
| 21 |
+
| 9 | `GET /api/v1/catalog/conversation-statuses` | Registry | None |
|
| 22 |
+
| 10 | `GET /api/v1/catalog/message-delivery-statuses` | Registry | None |
|
| 23 |
+
|
| 24 |
+
All endpoints return `ResponseEnvelope` with `Cache-Control: public, max-age=60`. No authentication required — these are public reference data.
|
| 25 |
+
|
| 26 |
+
## Backend Changes
|
| 27 |
+
|
| 28 |
+
### New Files
|
| 29 |
+
- **`backend/app/core/catalog_registry.py`** — Canonical enum registry. Exports typed lists (`INTEGRATION_PROVIDERS`, `AUTOMATION_NODE_TYPES`, etc.) and validation sets (`VALID_PROVIDERS`, `VALID_NODE_TYPES`, `VALID_TRIGGER_TYPES`).
|
| 30 |
+
- **`backend/app/api/v1/catalog.py`** — 10 GET-only endpoints using factory pattern for static endpoints.
|
| 31 |
+
- **`backend/tests/test_catalog.py`** — 12 test cases covering all endpoints, structure validation, cache headers, and 404 handling.
|
| 32 |
+
|
| 33 |
+
### Modified Files
|
| 34 |
+
- **`backend/main.py`** — Registered catalog router, added `/catalog` to maintenance mode exempt prefixes.
|
| 35 |
+
- **`backend/app/api/v1/integrations.py`** — Replaced hardcoded `["zoho", "whatsapp", "meta"]` with `VALID_PROVIDERS` from registry.
|
| 36 |
+
- **`backend/app/api/v1/automations.py`** — Added server-side validation of step types and trigger types against registry.
|
| 37 |
+
- **`backend/tests/test_automation.py`** — Updated test trigger type from `WEBHOOK` to `MESSAGE_INBOUND` (now validated).
|
| 38 |
+
|
| 39 |
+
## Frontend Changes
|
| 40 |
+
|
| 41 |
+
### New Files
|
| 42 |
+
- **`frontend/src/lib/catalog.ts`** — `useCatalog<T>(key)` hook with 60s in-memory cache, `catalogLabel()` helper.
|
| 43 |
+
|
| 44 |
+
### Hardcoded Sweep Report
|
| 45 |
+
|
| 46 |
+
| Page | What was removed | What replaced it |
|
| 47 |
+
|---|---|---|
|
| 48 |
+
| `/integrations` | `PROVIDER_METADATA` object (name, description, fields) | `useCatalog("integration-providers")` |
|
| 49 |
+
| `/inbox` | Hardcoded `<option>` status elements + switch-case labels | `useCatalog("conversation-statuses")` + `catalogLabel()` |
|
| 50 |
+
| `/automations/new` | Hardcoded trigger cards + step type buttons | `useCatalog("automation-node-types")` + `useCatalog("automation-trigger-types")` |
|
| 51 |
+
| `/admin/dispatch` | `STATUS_OPTIONS` array | `useCatalog("message-delivery-statuses")` |
|
| 52 |
+
| `/dispatch` | Hardcoded status labels in `getStatusBadge()` | `catalogLabel(deliveryStatuses, ...)` |
|
| 53 |
+
| `/plans` (marketing) | Hardcoded waitlist `<select>` options | `useCatalog<PlanCatalogEntry[]>("plans")` |
|
| 54 |
+
| `/admin/email-logs` | _(kept as-is)_ | Added traceability comment to registry |
|
| 55 |
+
| `/lib/admin-api.ts` | _(kept as-is)_ | Added deprecation comment on `MODULE_LABELS` |
|
| 56 |
+
|
| 57 |
+
### Intentionally Unchanged
|
| 58 |
+
- **Marketing tier cards** (`/plans` page) — Rich marketing copy (features, pricing, CTAs) is a presentation concern, not catalog data.
|
| 59 |
+
- **Admin email log filters** — Admin-internal statuses, different enum from message delivery. Kept as local constants with traceability comment.
|
| 60 |
+
- **Team page** — Static placeholder with no backend wiring.
|
| 61 |
+
|
| 62 |
+
## Test Results
|
| 63 |
+
|
| 64 |
+
```
|
| 65 |
+
Backend: 105 passed, 0 failed (16.60s)
|
| 66 |
+
Frontend: npm run build — 48 pages, 0 errors
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
## Key Decisions
|
| 70 |
+
|
| 71 |
+
1. **No auth on catalog** — Public reference data, safe with 60s cache header.
|
| 72 |
+
2. **No DB migration** — Reads existing Plan/Module tables + in-memory registry.
|
| 73 |
+
3. **Factory pattern** — 7 identical static handlers collapsed to 1 factory function.
|
| 74 |
+
4. **Icons stay frontend-side** — `icon_hint` in registry maps to Lucide components via local mapping.
|
| 75 |
+
5. **Maintenance mode exempt** — Catalog is reference data, available even during maintenance.
|
docs/missions/mission_19_data_flow_map.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Mission 19 — Data Flow Map
|
| 2 |
+
|
| 3 |
+
Maps every dynamic UI element to its API endpoint, service layer, and database source.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Plans & Billing
|
| 8 |
+
|
| 9 |
+
| UI Location | Component | API Endpoint | Service | DB Table |
|
| 10 |
+
|---|---|---|---|---|
|
| 11 |
+
| `/plans` waitlist dropdown | `WaitlistForm` | `GET /api/v1/catalog/plans` | catalog router | `plan` + `plan_entitlement` |
|
| 12 |
+
| `/admin/plans` list | Admin Plans page | `GET /api/v1/admin/plans` | admin router | `plan` |
|
| 13 |
+
| `/admin/plans/:id` detail | Plan detail page | `GET /api/v1/admin/plans/:id` | admin router | `plan` + `plan_entitlement` |
|
| 14 |
+
| `/admin/workspaces/:id` plan badge | Workspace detail | `GET /api/v1/admin/workspaces/:id/plan` | admin router | `workspace_plan_assignment` |
|
| 15 |
+
|
| 16 |
+
## Modules
|
| 17 |
+
|
| 18 |
+
| UI Location | Component | API Endpoint | Service | DB Table |
|
| 19 |
+
|---|---|---|---|---|
|
| 20 |
+
| `/admin/modules` toggle list | Modules page | `GET /api/v1/admin/modules` | admin router | `system_module_config` |
|
| 21 |
+
| `/admin/workspaces/:id` module overrides | Workspace detail | `GET /api/v1/admin/workspaces/:id/modules` | admin router | `workspace_module_override` |
|
| 22 |
+
| Catalog reference | Any page via `useCatalog` | `GET /api/v1/catalog/modules` | catalog router | `system_module_config` + registry labels |
|
| 23 |
+
|
| 24 |
+
## Roles
|
| 25 |
+
|
| 26 |
+
| UI Location | Component | API Endpoint | Service | DB Table |
|
| 27 |
+
|---|---|---|---|---|
|
| 28 |
+
| `/team` role badges | Team page (placeholder) | `GET /api/v1/catalog/workspace-roles` | catalog router | Registry (enum) |
|
| 29 |
+
| `/admin/agencies` role display | Agency detail | `GET /api/v1/catalog/admin-roles` | catalog router | Registry (enum) |
|
| 30 |
+
| Member invite role picker | Workspace settings | `GET /api/v1/catalog/workspace-roles` | catalog router | Registry (enum) |
|
| 31 |
+
|
| 32 |
+
## Integrations
|
| 33 |
+
|
| 34 |
+
| UI Location | Component | API Endpoint | Service | DB Table |
|
| 35 |
+
|---|---|---|---|---|
|
| 36 |
+
| `/integrations` provider cards | Integrations page | `GET /api/v1/catalog/integration-providers` | catalog router | Registry |
|
| 37 |
+
| `/integrations` connected list | Integrations page | `GET /api/v1/integrations` | integrations router | `integration` |
|
| 38 |
+
| `/integrations` connect modal fields | Dynamic form | `GET /api/v1/catalog/integration-providers` | catalog router | Registry (`.fields`) |
|
| 39 |
+
| `/integrations` provider validation | Backend | `POST /api/v1/integrations/:provider/connect` | integrations router | `VALID_PROVIDERS` set |
|
| 40 |
+
|
| 41 |
+
## Automations
|
| 42 |
+
|
| 43 |
+
| UI Location | Component | API Endpoint | Service | DB Table |
|
| 44 |
+
|---|---|---|---|---|
|
| 45 |
+
| `/automations/new` trigger cards | Builder wizard | `GET /api/v1/catalog/automation-trigger-types` | catalog router | Registry |
|
| 46 |
+
| `/automations/new` step type buttons | Builder wizard | `GET /api/v1/catalog/automation-node-types` | catalog router | Registry |
|
| 47 |
+
| `/automations/new` step labels | Review panel | `GET /api/v1/catalog/automation-node-types` | catalog router | Registry |
|
| 48 |
+
| `/automations` flow list | Automations page | `GET /api/v1/automations` | automations router | `flow` |
|
| 49 |
+
| Backend step validation | `create_from_builder` | — | `VALID_NODE_TYPES` set | Registry |
|
| 50 |
+
| Backend trigger validation | `create_from_builder` | — | `VALID_TRIGGER_TYPES` set | Registry |
|
| 51 |
+
|
| 52 |
+
## Inbox / Conversations
|
| 53 |
+
|
| 54 |
+
| UI Location | Component | API Endpoint | Service | DB Table |
|
| 55 |
+
|---|---|---|---|---|
|
| 56 |
+
| `/inbox` status filter dropdown | Inbox page | `GET /api/v1/catalog/conversation-statuses` | catalog router | Registry (enum) |
|
| 57 |
+
| `/inbox` status badges | `StatusBadge` component | `GET /api/v1/catalog/conversation-statuses` | catalog router | Registry (enum) |
|
| 58 |
+
| `/inbox` conversation list | Inbox page | `GET /api/v1/inbox/conversations` | inbox router | `conversation` |
|
| 59 |
+
| `/inbox` messages | Message panel | `GET /api/v1/inbox/conversations/:id/messages` | inbox router | `message` |
|
| 60 |
+
|
| 61 |
+
## Dispatch / Message Delivery
|
| 62 |
+
|
| 63 |
+
| UI Location | Component | API Endpoint | Service | DB Table |
|
| 64 |
+
|---|---|---|---|---|
|
| 65 |
+
| `/admin/dispatch` status filter | Admin dispatch page | `GET /api/v1/catalog/message-delivery-statuses` | catalog router | Registry (enum) |
|
| 66 |
+
| `/admin/dispatch` message list | Admin dispatch page | `GET /api/v1/admin/dispatch` | admin router | `outbound_message` |
|
| 67 |
+
| `/dispatch` status badges | Dashboard dispatch | `GET /api/v1/catalog/message-delivery-statuses` | catalog router | Registry (enum) |
|
| 68 |
+
| `/dispatch` message queue | Dashboard dispatch | `GET /api/v1/dispatch/queue` | dispatch router | `outbound_message` |
|
| 69 |
+
|
| 70 |
+
## Email Engine
|
| 71 |
+
|
| 72 |
+
| UI Location | Component | API Endpoint | Service | DB Table |
|
| 73 |
+
|---|---|---|---|---|
|
| 74 |
+
| `/admin/email-logs` status filter | Email logs page | Local constants (registry-traced) | — | — |
|
| 75 |
+
| `/admin/email-logs` type filter | Email logs page | Local constants (registry-traced) | — | — |
|
| 76 |
+
| `/admin/email-logs` log list | Email logs page | `GET /api/v1/admin/email-logs` | admin router | `email_outbox` |
|
| 77 |
+
| `/admin/email-logs` retry action | Retry button | `POST /api/v1/admin/email-logs/:id/retry` | admin router | `email_outbox` |
|
| 78 |
+
|
| 79 |
+
## Analytics & Dashboard
|
| 80 |
+
|
| 81 |
+
| UI Location | Component | API Endpoint | Service | DB Table |
|
| 82 |
+
|---|---|---|---|---|
|
| 83 |
+
| `/dashboard` stats cards | Dashboard page | `GET /api/v1/analytics/summary` | analytics router | Aggregated queries |
|
| 84 |
+
| `/dashboard` time series | Charts | `GET /api/v1/analytics/time-series` | analytics router | Aggregated queries |
|
| 85 |
+
|
| 86 |
+
## Audit & Runtime Events
|
| 87 |
+
|
| 88 |
+
| UI Location | Component | API Endpoint | Service | DB Table |
|
| 89 |
+
|---|---|---|---|---|
|
| 90 |
+
| `/admin/audit-log` list | Audit log page | `GET /api/v1/admin/audit-log` | admin router | `audit_log` |
|
| 91 |
+
| `/admin/audit-log` detail | Detail drawer | `GET /api/v1/admin/audit-log/:id` | admin router | `audit_log` |
|
| 92 |
+
| `/admin/runtime-events` list | Runtime events page | `GET /api/v1/admin/runtime-events` | admin router | `runtime_event` |
|
| 93 |
+
| `/admin/runtime-events` detail | Detail drawer | `GET /api/v1/admin/runtime-events/:id` | admin router | `runtime_event` |
|
| 94 |
+
|
| 95 |
+
## Settings
|
| 96 |
+
|
| 97 |
+
| UI Location | Component | API Endpoint | Service | DB Table |
|
| 98 |
+
|---|---|---|---|---|
|
| 99 |
+
| `/settings` workspace name | Settings page | `GET /api/v1/workspaces/current` | workspaces router | `workspace` |
|
| 100 |
+
| `/admin/system-settings` | System settings | `GET /api/v1/admin/system-settings` | admin router | `system_setting` |
|
| 101 |
+
|
| 102 |
+
---
|
| 103 |
+
|
| 104 |
+
## Registry → Validation Cross-Reference
|
| 105 |
+
|
| 106 |
+
| Registry Constant | Used By (Validation) | Used By (Catalog Endpoint) |
|
| 107 |
+
|---|---|---|
|
| 108 |
+
| `VALID_PROVIDERS` | `integrations.py` line 46 | `/catalog/integration-providers` |
|
| 109 |
+
| `VALID_NODE_TYPES` | `automations.py` line 56 | `/catalog/automation-node-types` |
|
| 110 |
+
| `VALID_TRIGGER_TYPES` | `automations.py` line 58 | `/catalog/automation-trigger-types` |
|
| 111 |
+
| `CONVERSATION_STATUSES` | — (enum-enforced in DB) | `/catalog/conversation-statuses` |
|
| 112 |
+
| `MESSAGE_DELIVERY_STATUSES` | — (enum-enforced in DB) | `/catalog/message-delivery-statuses` |
|
| 113 |
+
| `WORKSPACE_ROLES` | — (enum-enforced in DB) | `/catalog/workspace-roles` |
|
| 114 |
+
| `AGENCY_ROLES` | — (enum-enforced in DB) | `/catalog/admin-roles` |
|
| 115 |
+
| `MODULE_LABELS` | — (display only) | `/catalog/modules` |
|
docs/missions/mission_20.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Mission 20 — Prompt Studio Knowledge Base v2 + Dynamic Lead Qualification Builder
|
| 2 |
+
|
| 3 |
+
## Summary
|
| 4 |
+
|
| 5 |
+
Mission 20 eliminates the three critical gaps in LeadPilot's AI pipeline:
|
| 6 |
+
|
| 7 |
+
1. **Knowledge files now flow into AI context** — uploaded documents (TXT, CSV, PDF) are extracted and injected into every AI conversation via the new prompt compiler.
|
| 8 |
+
2. **Single source of truth for prompt compilation** — `prompt_compiler.py` replaces two divergent context-assembly paths (Test Chat vs Runtime) with one canonical pipeline.
|
| 9 |
+
3. **Automation AI steps now use goal/tasks/extra_instructions** — the runtime passes step-level config through the compiler, so AI_REPLY nodes finally execute under the configured goals.
|
| 10 |
+
4. **Dynamic lead qualification** — workspace-level configurable questions + statuses with admin defaults/override.
|
| 11 |
+
|
| 12 |
+
## What Changed
|
| 13 |
+
|
| 14 |
+
### Backend
|
| 15 |
+
|
| 16 |
+
| File | Change |
|
| 17 |
+
|---|---|
|
| 18 |
+
| `app/models/models.py` | Added `sha256_hash` + `status` to `WorkspaceKnowledgeFile`; new `QualificationConfig` model |
|
| 19 |
+
| `alembic/versions/f6g7h8i9j0k1_...py` | Migration: 2 new columns + 1 new table |
|
| 20 |
+
| `app/api/v1/knowledge.py` | SHA256 dedupe, PDF extraction (PyMuPDF), workspace-isolated storage, download endpoint |
|
| 21 |
+
| `app/services/prompt_compiler.py` | **NEW** — single source of truth context compiler |
|
| 22 |
+
| `app/api/v1/test_chat.py` | Uses compiler instead of inline assembly |
|
| 23 |
+
| `app/domain/runtime.py` | Uses compiler with `step_config` for AI_REPLY |
|
| 24 |
+
| `app/api/v1/qualification.py` | **NEW** — GET/POST qualification config (lazy-create pattern) |
|
| 25 |
+
| `app/api/v1/admin.py` | 3 new endpoints: qualification defaults, workspace reset |
|
| 26 |
+
| `main.py` | Registered qualification router |
|
| 27 |
+
|
| 28 |
+
### Frontend
|
| 29 |
+
|
| 30 |
+
| File | Change |
|
| 31 |
+
|---|---|
|
| 32 |
+
| `prompt-studio/page.tsx` | 4th "Lead Qualification" tab with questions/statuses CRUD; Knowledge tab polished with status badges + download buttons |
|
| 33 |
+
|
| 34 |
+
### Tests
|
| 35 |
+
|
| 36 |
+
| File | Tests |
|
| 37 |
+
|---|---|
|
| 38 |
+
| `tests/test_knowledge.py` | 6 tests — upload, list, dedupe, delete, download, unsupported format |
|
| 39 |
+
| `tests/test_prompt_compiler.py` | 5 tests — all sections, step_config, exclude files, exclude qualification, no-config default |
|
| 40 |
+
| `tests/test_qualification.py` | 3 tests — lazy create, update + version increment, idempotent GET |
|
| 41 |
+
|
| 42 |
+
**Total: 118 tests passing (was 57 pre-mission)**
|
| 43 |
+
|
| 44 |
+
## Architecture: Prompt Compiler
|
| 45 |
+
|
| 46 |
+
```
|
| 47 |
+
compile_workspace_prompt(workspace_id, db, *, include_files, include_qualification, step_config)
|
| 48 |
+
│
|
| 49 |
+
├── 1. system_prompt_text (from PromptVersion)
|
| 50 |
+
├── 2. === BUSINESS PROFILE === (business_profile_json)
|
| 51 |
+
├── 3. === GUARDRAILS === (guardrails_json)
|
| 52 |
+
├── 4. === KNOWLEDGE BASE (FILES) === (READY files, 4000 char cap each)
|
| 53 |
+
├── 5. === LEAD QUALIFICATION === (enabled questions sorted by order)
|
| 54 |
+
└── 6. === STEP GOAL / TASKS / STEP INSTRUCTIONS === (if step_config provided)
|
| 55 |
+
|
| 56 |
+
Returns: CompiledPrompt(system_instruction, temperature, max_tokens, metadata)
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
Both Test Chat and Runtime AI_REPLY call this single function.
|
| 60 |
+
|
| 61 |
+
## New API Endpoints
|
| 62 |
+
|
| 63 |
+
| Method | Path | Description |
|
| 64 |
+
|---|---|---|
|
| 65 |
+
| GET | `/api/v1/qualification-config` | Get or lazy-create workspace qualification config |
|
| 66 |
+
| POST | `/api/v1/qualification-config` | Update questions + statuses, version auto-increments |
|
| 67 |
+
| GET | `/api/v1/knowledge/files/{id}/download` | Download knowledge file |
|
| 68 |
+
| GET | `/api/v1/admin/qualification-defaults` | Admin: get global defaults |
|
| 69 |
+
| PUT | `/api/v1/admin/qualification-defaults` | Admin: set global defaults |
|
| 70 |
+
| POST | `/api/v1/admin/workspaces/{id}/qualification-reset` | Admin: reset workspace to defaults |
|
| 71 |
+
|
| 72 |
+
## How to Test
|
| 73 |
+
|
| 74 |
+
1. **Backend tests**: `cd backend && rm -f test.db && python3 -m pytest tests/ -v`
|
| 75 |
+
2. **Frontend build**: `cd frontend && npm run build`
|
| 76 |
+
3. **Manual — Knowledge files**: Upload .txt in Prompt Studio Knowledge tab → open Test Chat → AI should reference file content
|
| 77 |
+
4. **Manual — Automation AI steps**: Create automation with AI_REPLY step goal "Schedule a demo" → trigger → AI should mention scheduling
|
| 78 |
+
5. **Manual — Qualification**: Edit questions in Lead Qualification tab → save → Test Chat AI should collect those specific details
|
docs/missions/mission_20_baseline.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Mission 20 — Pre-Mission Baseline
|
| 2 |
+
|
| 3 |
+
Captured before any Mission 20 changes.
|
| 4 |
+
|
| 5 |
+
## State of AI Context Compilation
|
| 6 |
+
|
| 7 |
+
### Test Chat (`test_chat.py:101-116`)
|
| 8 |
+
```python
|
| 9 |
+
# Fetched PromptConfig + PromptVersion manually
|
| 10 |
+
# Built system instruction as: system_prompt_text + json.dumps(business_profile_json) + json.dumps(guardrails_json)
|
| 11 |
+
# Knowledge files: NOT included
|
| 12 |
+
# Qualification: NOT included
|
| 13 |
+
# Step config: N/A
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
### Runtime AI_REPLY (`runtime.py:285-333`)
|
| 17 |
+
```python
|
| 18 |
+
# Built system instruction as: system_prompt_text + "Business Context:" + business_profile_json
|
| 19 |
+
# Guardrails: NOT included
|
| 20 |
+
# Knowledge files: NOT included
|
| 21 |
+
# Qualification: NOT included
|
| 22 |
+
# Step config (goal/tasks/extra_instructions): NOT read despite being stored in definition_json
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
**Result**: Two divergent prompts from the same config. Knowledge files uploaded but never used. Automation goals collected but ignored.
|
| 26 |
+
|
| 27 |
+
## Knowledge Files
|
| 28 |
+
|
| 29 |
+
- `WorkspaceKnowledgeFile` model existed with `extracted_text` field
|
| 30 |
+
- Upload endpoint extracted text for .txt, .md, .json, .csv
|
| 31 |
+
- No PDF support
|
| 32 |
+
- No SHA256 dedupe
|
| 33 |
+
- No download endpoint
|
| 34 |
+
- No status tracking (READY/FAILED)
|
| 35 |
+
- Storage path: flat `storage/knowledge/{uuid}{ext}` (not workspace-isolated)
|
| 36 |
+
|
| 37 |
+
## Qualification
|
| 38 |
+
|
| 39 |
+
- Hardcoded checkboxes in Prompt Studio editor tab: "Full Name", "Company Email", "Budget Range", etc.
|
| 40 |
+
- Stored as `required_details[]` in structured_data form
|
| 41 |
+
- No `QualificationConfig` model
|
| 42 |
+
- No admin defaults/override
|
| 43 |
+
- No qualification statuses
|
| 44 |
+
|
| 45 |
+
## Prompt Studio Frontend
|
| 46 |
+
|
| 47 |
+
- 3 tabs: Assistant Configuration, Knowledge Base, Version History
|
| 48 |
+
- Knowledge tab: upload/delete only, no status indicators
|
| 49 |
+
- No qualification management tab
|
| 50 |
+
|
| 51 |
+
## Test Count
|
| 52 |
+
|
| 53 |
+
57 tests across 13 files, all passing.
|
frontend/src/app/(admin)/admin/dispatch/page.tsx
CHANGED
|
@@ -5,10 +5,10 @@ import { adminApi } from "@/lib/admin-api";
|
|
| 5 |
import { Send, Loader2, Info, RefreshCw, RotateCcw, XCircle } from "lucide-react";
|
| 6 |
import { toast } from "sonner";
|
| 7 |
import { cn } from "@/lib/utils";
|
| 8 |
-
|
| 9 |
-
const STATUS_OPTIONS = ["", "PENDING", "SENDING", "SENT", "FAILED"];
|
| 10 |
|
| 11 |
export default function AdminDispatchPage() {
|
|
|
|
| 12 |
const [items, setItems] = useState<any[]>([]);
|
| 13 |
const [total, setTotal] = useState(0);
|
| 14 |
const [loading, setLoading] = useState(true);
|
|
@@ -82,8 +82,9 @@ export default function AdminDispatchPage() {
|
|
| 82 |
onChange={(e) => setStatus(e.target.value)}
|
| 83 |
className="bg-white/5 border border-white/10 text-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
| 84 |
>
|
| 85 |
-
|
| 86 |
-
|
|
|
|
| 87 |
))}
|
| 88 |
</select>
|
| 89 |
</div>
|
|
|
|
| 5 |
import { Send, Loader2, Info, RefreshCw, RotateCcw, XCircle } from "lucide-react";
|
| 6 |
import { toast } from "sonner";
|
| 7 |
import { cn } from "@/lib/utils";
|
| 8 |
+
import { useCatalog } from "@/lib/catalog";
|
|
|
|
| 9 |
|
| 10 |
export default function AdminDispatchPage() {
|
| 11 |
+
const { data: deliveryStatuses } = useCatalog("message-delivery-statuses");
|
| 12 |
const [items, setItems] = useState<any[]>([]);
|
| 13 |
const [total, setTotal] = useState(0);
|
| 14 |
const [loading, setLoading] = useState(true);
|
|
|
|
| 82 |
onChange={(e) => setStatus(e.target.value)}
|
| 83 |
className="bg-white/5 border border-white/10 text-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
|
| 84 |
>
|
| 85 |
+
<option value="" className="bg-slate-900">All Statuses</option>
|
| 86 |
+
{deliveryStatuses?.map((s) => (
|
| 87 |
+
<option key={s.key} value={s.key.toUpperCase()} className="bg-slate-900">{s.label}</option>
|
| 88 |
))}
|
| 89 |
</select>
|
| 90 |
</div>
|
frontend/src/app/(admin)/admin/email-logs/page.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { Mail, Loader2, Info, RefreshCw, RotateCcw } from "lucide-react";
|
|
| 6 |
import { toast } from "sonner";
|
| 7 |
import { cn } from "@/lib/utils";
|
| 8 |
|
|
|
|
| 9 |
const STATUS_OPTIONS = ["", "PENDING", "SENT", "FAILED", "PROCESSING"];
|
| 10 |
const EMAIL_TYPE_OPTIONS = ["", "welcome", "verify_email", "password_reset", "invite"];
|
| 11 |
|
|
|
|
| 6 |
import { toast } from "sonner";
|
| 7 |
import { cn } from "@/lib/utils";
|
| 8 |
|
| 9 |
+
// Source: backend/app/core/catalog_registry.py — EMAIL_OUTBOX_STATUSES / EMAIL_TYPES
|
| 10 |
const STATUS_OPTIONS = ["", "PENDING", "SENT", "FAILED", "PROCESSING"];
|
| 11 |
const EMAIL_TYPE_OPTIONS = ["", "welcome", "verify_email", "password_reset", "invite"];
|
| 12 |
|
frontend/src/app/(dashboard)/automations/new/page.tsx
CHANGED
|
@@ -21,9 +21,25 @@ import {
|
|
| 21 |
} from "lucide-react";
|
| 22 |
import { cn } from "@/lib/utils";
|
| 23 |
import { apiClient } from "@/lib/api";
|
|
|
|
| 24 |
import Link from "next/link";
|
| 25 |
import { useRouter } from "next/navigation";
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
type StepType = "AI_REPLY" | "SEND_MESSAGE" | "HUMAN_HANDOVER" | "TAG_CONTACT";
|
| 28 |
|
| 29 |
interface Step {
|
|
@@ -34,6 +50,7 @@ interface Step {
|
|
| 34 |
|
| 35 |
export default function NewAutomationPage() {
|
| 36 |
const router = useRouter();
|
|
|
|
| 37 |
const [currentStep, setCurrentStep] = useState(1);
|
| 38 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 39 |
|
|
@@ -185,17 +202,13 @@ export default function NewAutomationPage() {
|
|
| 185 |
</div>
|
| 186 |
|
| 187 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 188 |
-
{
|
| 189 |
-
{ id: "whatsapp", name: "WhatsApp Message", type: "MESSAGE_INBOUND", icon: MessageSquare, color: "text-green-600", bg: "bg-green-50" },
|
| 190 |
-
{ id: "meta-msg", name: "Messenger/Instagram", type: "MESSAGE_INBOUND", icon: Facebook, color: "text-blue-600", bg: "bg-blue-50" },
|
| 191 |
-
{ id: "leadgen", name: "Meta Lead Ads", type: "LEAD_AD_SUBMIT", icon: Zap, color: "text-orange-600", bg: "bg-orange-50" }
|
| 192 |
-
].map((t) => (
|
| 193 |
<button
|
| 194 |
key={t.id}
|
| 195 |
-
onClick={() => setTrigger({ ...trigger, type: t.
|
| 196 |
className={cn(
|
| 197 |
"flex items-start gap-4 p-5 rounded-2xl border transition-all text-left group hover:scale-[1.02]",
|
| 198 |
-
(trigger.type === t.
|
| 199 |
? "border-teal-500 bg-teal-50/50 ring-2 ring-teal-500/10"
|
| 200 |
: "border-border bg-white hover:border-teal-200"
|
| 201 |
)}
|
|
@@ -226,21 +239,19 @@ export default function NewAutomationPage() {
|
|
| 226 |
Add Step
|
| 227 |
</button>
|
| 228 |
<div className="absolute right-0 top-full mt-2 w-56 bg-white border border-border shadow-xl rounded-xl p-2 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-20">
|
| 229 |
-
{[
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
</button>
|
| 243 |
-
))}
|
| 244 |
</div>
|
| 245 |
</div>
|
| 246 |
</div>
|
|
@@ -265,11 +276,12 @@ export default function NewAutomationPage() {
|
|
| 265 |
{index + 1}
|
| 266 |
</span>
|
| 267 |
<div className="flex items-center gap-2 font-bold text-slate-700">
|
| 268 |
-
{
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
|
|
|
| 273 |
</div>
|
| 274 |
</div>
|
| 275 |
|
|
@@ -407,7 +419,7 @@ export default function NewAutomationPage() {
|
|
| 407 |
{steps.map((s, i) => (
|
| 408 |
<div key={s.id} className="text-sm flex items-center gap-3 text-slate-600 bg-slate-50 p-3 rounded-xl border border-border/50">
|
| 409 |
<span className="w-5 h-5 bg-white border border-border text-[10px] flex items-center justify-center rounded-full font-bold">{i + 1}</span>
|
| 410 |
-
{s.type.replace("_", " ")}
|
| 411 |
</div>
|
| 412 |
))}
|
| 413 |
</div>
|
|
|
|
| 21 |
} from "lucide-react";
|
| 22 |
import { cn } from "@/lib/utils";
|
| 23 |
import { apiClient } from "@/lib/api";
|
| 24 |
+
import { useCatalog, catalogLabel, type CatalogEntry } from "@/lib/catalog";
|
| 25 |
import Link from "next/link";
|
| 26 |
import { useRouter } from "next/navigation";
|
| 27 |
|
| 28 |
+
// Icon mapping for catalog icon_hint → Lucide component
|
| 29 |
+
const NODE_TYPE_ICONS: Record<string, any> = {
|
| 30 |
+
bot: Bot,
|
| 31 |
+
send: Send,
|
| 32 |
+
user: UserRound,
|
| 33 |
+
tag: TagIcon,
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
// Trigger presentation — combines catalog trigger types with platform-specific UI
|
| 37 |
+
const TRIGGER_CARDS = [
|
| 38 |
+
{ id: "whatsapp", triggerKey: "MESSAGE_INBOUND", platform: "whatsapp", name: "WhatsApp Message", icon: MessageSquare, color: "text-green-600", bg: "bg-green-50" },
|
| 39 |
+
{ id: "meta-msg", triggerKey: "MESSAGE_INBOUND", platform: "meta", name: "Messenger/Instagram", icon: Facebook, color: "text-blue-600", bg: "bg-blue-50" },
|
| 40 |
+
{ id: "leadgen", triggerKey: "LEAD_AD_SUBMIT", platform: "meta", name: "Meta Lead Ads", icon: Zap, color: "text-orange-600", bg: "bg-orange-50" },
|
| 41 |
+
];
|
| 42 |
+
|
| 43 |
type StepType = "AI_REPLY" | "SEND_MESSAGE" | "HUMAN_HANDOVER" | "TAG_CONTACT";
|
| 44 |
|
| 45 |
interface Step {
|
|
|
|
| 50 |
|
| 51 |
export default function NewAutomationPage() {
|
| 52 |
const router = useRouter();
|
| 53 |
+
const { data: nodeTypes } = useCatalog("automation-node-types");
|
| 54 |
const [currentStep, setCurrentStep] = useState(1);
|
| 55 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 56 |
|
|
|
|
| 202 |
</div>
|
| 203 |
|
| 204 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 205 |
+
{TRIGGER_CARDS.map((t) => (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
<button
|
| 207 |
key={t.id}
|
| 208 |
+
onClick={() => setTrigger({ ...trigger, type: t.triggerKey, platform: t.platform })}
|
| 209 |
className={cn(
|
| 210 |
"flex items-start gap-4 p-5 rounded-2xl border transition-all text-left group hover:scale-[1.02]",
|
| 211 |
+
(trigger.type === t.triggerKey && trigger.platform === t.platform)
|
| 212 |
? "border-teal-500 bg-teal-50/50 ring-2 ring-teal-500/10"
|
| 213 |
: "border-border bg-white hover:border-teal-200"
|
| 214 |
)}
|
|
|
|
| 239 |
Add Step
|
| 240 |
</button>
|
| 241 |
<div className="absolute right-0 top-full mt-2 w-56 bg-white border border-border shadow-xl rounded-xl p-2 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-20">
|
| 242 |
+
{(nodeTypes || []).map((nt) => {
|
| 243 |
+
const Icon = NODE_TYPE_ICONS[nt.icon_hint] || Zap;
|
| 244 |
+
return (
|
| 245 |
+
<button
|
| 246 |
+
key={nt.key}
|
| 247 |
+
onClick={() => addStep(nt.key as StepType)}
|
| 248 |
+
className="w-full flex items-center gap-3 p-2.5 hover:bg-teal-50 hover:text-teal-600 rounded-lg text-sm font-medium transition-colors"
|
| 249 |
+
>
|
| 250 |
+
<Icon className="w-4 h-4" />
|
| 251 |
+
{nt.label}
|
| 252 |
+
</button>
|
| 253 |
+
);
|
| 254 |
+
})}
|
|
|
|
|
|
|
| 255 |
</div>
|
| 256 |
</div>
|
| 257 |
</div>
|
|
|
|
| 276 |
{index + 1}
|
| 277 |
</span>
|
| 278 |
<div className="flex items-center gap-2 font-bold text-slate-700">
|
| 279 |
+
{(() => {
|
| 280 |
+
const nt = nodeTypes?.find(n => n.key === step.type);
|
| 281 |
+
const Icon = nt ? (NODE_TYPE_ICONS[nt.icon_hint] || Zap) : Zap;
|
| 282 |
+
return <Icon className="w-4 h-4 text-teal-600" />;
|
| 283 |
+
})()}
|
| 284 |
+
{catalogLabel(nodeTypes, step.type, step.type.replace("_", " "))}
|
| 285 |
</div>
|
| 286 |
</div>
|
| 287 |
|
|
|
|
| 419 |
{steps.map((s, i) => (
|
| 420 |
<div key={s.id} className="text-sm flex items-center gap-3 text-slate-600 bg-slate-50 p-3 rounded-xl border border-border/50">
|
| 421 |
<span className="w-5 h-5 bg-white border border-border text-[10px] flex items-center justify-center rounded-full font-bold">{i + 1}</span>
|
| 422 |
+
{catalogLabel(nodeTypes, s.type, s.type.replace("_", " "))}
|
| 423 |
</div>
|
| 424 |
))}
|
| 425 |
</div>
|
frontend/src/app/(dashboard)/dispatch/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useState } from "react";
|
| 4 |
import { apiClient } from "@/lib/api";
|
|
|
|
| 5 |
import {
|
| 6 |
RefreshCcw,
|
| 7 |
Play,
|
|
@@ -25,6 +26,7 @@ interface OutboundMessage {
|
|
| 25 |
}
|
| 26 |
|
| 27 |
export default function DispatchPage() {
|
|
|
|
| 28 |
const [messages, setMessages] = useState<OutboundMessage[]>([]);
|
| 29 |
const [loading, setLoading] = useState(true);
|
| 30 |
const [running, setRunning] = useState(false);
|
|
@@ -66,29 +68,30 @@ export default function DispatchPage() {
|
|
| 66 |
};
|
| 67 |
|
| 68 |
const getStatusBadge = (status: string) => {
|
|
|
|
| 69 |
switch (status) {
|
| 70 |
case "sent":
|
| 71 |
return (
|
| 72 |
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800">
|
| 73 |
-
<CheckCircle2 className="w-3 h-3" />
|
| 74 |
</span>
|
| 75 |
);
|
| 76 |
case "failed":
|
| 77 |
return (
|
| 78 |
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
| 79 |
-
<XCircle className="w-3 h-3" />
|
| 80 |
</span>
|
| 81 |
);
|
| 82 |
case "sending":
|
| 83 |
return (
|
| 84 |
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 animate-pulse">
|
| 85 |
-
<Send className="w-3 h-3" />
|
| 86 |
</span>
|
| 87 |
);
|
| 88 |
default:
|
| 89 |
return (
|
| 90 |
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-800">
|
| 91 |
-
<Clock className="w-3 h-3" /> Pending
|
| 92 |
</span>
|
| 93 |
);
|
| 94 |
}
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useState } from "react";
|
| 4 |
import { apiClient } from "@/lib/api";
|
| 5 |
+
import { useCatalog, catalogLabel } from "@/lib/catalog";
|
| 6 |
import {
|
| 7 |
RefreshCcw,
|
| 8 |
Play,
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
export default function DispatchPage() {
|
| 29 |
+
const { data: deliveryStatuses } = useCatalog("message-delivery-statuses");
|
| 30 |
const [messages, setMessages] = useState<OutboundMessage[]>([]);
|
| 31 |
const [loading, setLoading] = useState(true);
|
| 32 |
const [running, setRunning] = useState(false);
|
|
|
|
| 68 |
};
|
| 69 |
|
| 70 |
const getStatusBadge = (status: string) => {
|
| 71 |
+
const label = catalogLabel(deliveryStatuses, status, status);
|
| 72 |
switch (status) {
|
| 73 |
case "sent":
|
| 74 |
return (
|
| 75 |
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800">
|
| 76 |
+
<CheckCircle2 className="w-3 h-3" /> {label}
|
| 77 |
</span>
|
| 78 |
);
|
| 79 |
case "failed":
|
| 80 |
return (
|
| 81 |
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
| 82 |
+
<XCircle className="w-3 h-3" /> {label}
|
| 83 |
</span>
|
| 84 |
);
|
| 85 |
case "sending":
|
| 86 |
return (
|
| 87 |
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 animate-pulse">
|
| 88 |
+
<Send className="w-3 h-3" /> {label}
|
| 89 |
</span>
|
| 90 |
);
|
| 91 |
default:
|
| 92 |
return (
|
| 93 |
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-800">
|
| 94 |
+
<Clock className="w-3 h-3" /> {catalogLabel(deliveryStatuses, "pending", "Pending")}
|
| 95 |
</span>
|
| 96 |
);
|
| 97 |
}
|
frontend/src/app/(dashboard)/inbox/page.tsx
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
| 21 |
import { formatDistanceToNow } from "date-fns";
|
| 22 |
import { cn } from "@/lib/utils";
|
| 23 |
import { apiClient } from "@/lib/api";
|
|
|
|
| 24 |
import { useSearchParams, useRouter } from "next/navigation";
|
| 25 |
import { toast } from "sonner";
|
| 26 |
import TimelinePanel from "@/components/inbox/TimelinePanel";
|
|
@@ -53,6 +54,7 @@ type Conversation = {
|
|
| 53 |
// --- Components ---
|
| 54 |
|
| 55 |
export default function InboxPage() {
|
|
|
|
| 56 |
const [conversations, setConversations] = useState<Conversation[]>([]);
|
| 57 |
const [selectedId, setSelectedId] = useState<string | null>(null);
|
| 58 |
const [thread, setThread] = useState<{ messages: Message[], status: string, contact: any } | null>(null);
|
|
@@ -187,9 +189,9 @@ export default function InboxPage() {
|
|
| 187 |
onChange={(e) => setStatusFilter(e.target.value)}
|
| 188 |
>
|
| 189 |
<option value="all">All Status</option>
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
</select>
|
| 194 |
</div>
|
| 195 |
</div>
|
|
@@ -397,15 +399,25 @@ export default function InboxPage() {
|
|
| 397 |
|
| 398 |
// --- Subcomponents ---
|
| 399 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
function StatusBadge({ status }: { status: "bot_active" | "human_takeover" | "closed" }) {
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
}
|
| 409 |
}
|
| 410 |
|
| 411 |
function DeliveryStatusBadge({ status, error }: { status: string, error?: string }) {
|
|
|
|
| 21 |
import { formatDistanceToNow } from "date-fns";
|
| 22 |
import { cn } from "@/lib/utils";
|
| 23 |
import { apiClient } from "@/lib/api";
|
| 24 |
+
import { useCatalog, catalogLabel, type CatalogEntry } from "@/lib/catalog";
|
| 25 |
import { useSearchParams, useRouter } from "next/navigation";
|
| 26 |
import { toast } from "sonner";
|
| 27 |
import TimelinePanel from "@/components/inbox/TimelinePanel";
|
|
|
|
| 54 |
// --- Components ---
|
| 55 |
|
| 56 |
export default function InboxPage() {
|
| 57 |
+
const { data: convStatuses } = useCatalog("conversation-statuses");
|
| 58 |
const [conversations, setConversations] = useState<Conversation[]>([]);
|
| 59 |
const [selectedId, setSelectedId] = useState<string | null>(null);
|
| 60 |
const [thread, setThread] = useState<{ messages: Message[], status: string, contact: any } | null>(null);
|
|
|
|
| 189 |
onChange={(e) => setStatusFilter(e.target.value)}
|
| 190 |
>
|
| 191 |
<option value="all">All Status</option>
|
| 192 |
+
{convStatuses?.map((s) => (
|
| 193 |
+
<option key={s.key} value={s.key}>{s.label}</option>
|
| 194 |
+
))}
|
| 195 |
</select>
|
| 196 |
</div>
|
| 197 |
</div>
|
|
|
|
| 399 |
|
| 400 |
// --- Subcomponents ---
|
| 401 |
|
| 402 |
+
const STATUS_STYLES: Record<string, string> = {
|
| 403 |
+
bot_active: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
| 404 |
+
human_takeover: "bg-amber-100 text-amber-700 border-amber-200",
|
| 405 |
+
closed: "bg-slate-100 text-slate-600 border-slate-200",
|
| 406 |
+
};
|
| 407 |
+
const STATUS_FALLBACK_LABELS: Record<string, string> = {
|
| 408 |
+
bot_active: "AI Active",
|
| 409 |
+
human_takeover: "Human Control",
|
| 410 |
+
closed: "Closed",
|
| 411 |
+
};
|
| 412 |
+
|
| 413 |
function StatusBadge({ status }: { status: "bot_active" | "human_takeover" | "closed" }) {
|
| 414 |
+
const style = STATUS_STYLES[status] || STATUS_STYLES.closed;
|
| 415 |
+
const label = STATUS_FALLBACK_LABELS[status] || status;
|
| 416 |
+
return (
|
| 417 |
+
<div className={cn("flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold border uppercase", style)}>
|
| 418 |
+
{label}
|
| 419 |
+
</div>
|
| 420 |
+
);
|
|
|
|
| 421 |
}
|
| 422 |
|
| 423 |
function DeliveryStatusBadge({ status, error }: { status: string, error?: string }) {
|
frontend/src/app/(dashboard)/integrations/page.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
| 15 |
import Link from "next/link";
|
| 16 |
import { cn } from "@/lib/utils";
|
| 17 |
import { apiClient } from "@/lib/api";
|
|
|
|
| 18 |
|
| 19 |
type Status = "connected" | "disconnected" | "error";
|
| 20 |
|
|
@@ -28,28 +29,15 @@ interface IntegrationProvider {
|
|
| 28 |
icon: string;
|
| 29 |
}
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
fields: [{ name: "org_id", label: "Organization ID", type: "text" }, { name: "access_token", label: "Access Token", type: "password" }]
|
| 37 |
-
},
|
| 38 |
-
whatsapp: {
|
| 39 |
-
name: "WhatsApp Cloud API",
|
| 40 |
-
description: "Official API for professional messaging and automation.",
|
| 41 |
-
icon: "https://upload.wikimedia.org/wikipedia/commons/6/6b/WhatsApp.svg",
|
| 42 |
-
fields: [{ name: "phone_number_id", label: "Phone Number ID", type: "text" }, { name: "access_token", label: "Access Token", type: "password" }]
|
| 43 |
-
},
|
| 44 |
-
meta: {
|
| 45 |
-
name: "Meta (Instagram/Messenger)",
|
| 46 |
-
description: "Automate responses for Instagram DM and Facebook Messenger.",
|
| 47 |
-
icon: "https://upload.wikimedia.org/wikipedia/commons/7/7b/Meta_Platforms_Inc._logo.svg",
|
| 48 |
-
fields: [{ name: "page_id", label: "Page ID", type: "text" }, { name: "access_token", label: "Access Token", type: "password" }]
|
| 49 |
-
}
|
| 50 |
};
|
| 51 |
|
| 52 |
export default function IntegrationsPage() {
|
|
|
|
| 53 |
const [integrations, setIntegrations] = useState<IntegrationProvider[]>([]);
|
| 54 |
const [isLoading, setIsLoading] = useState(true);
|
| 55 |
const [configModal, setConfigModal] = useState<{ provider: string } | null>(null);
|
|
@@ -57,15 +45,21 @@ export default function IntegrationsPage() {
|
|
| 57 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 58 |
const [fetchError, setFetchError] = useState<string | null>(null);
|
| 59 |
|
| 60 |
-
const
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
status: "disconnected" as Status,
|
| 68 |
-
lastChecked: undefined
|
| 69 |
}));
|
| 70 |
|
| 71 |
const fetchIntegrations = async () => {
|
|
@@ -73,39 +67,39 @@ export default function IntegrationsPage() {
|
|
| 73 |
setFetchError(null);
|
| 74 |
const res = await apiClient.get<any[]>("/integrations");
|
| 75 |
if (res.success && res.data) {
|
| 76 |
-
const mapped =
|
| 77 |
-
const backendRecord = res.data?.find(r => r.provider ===
|
| 78 |
return {
|
| 79 |
-
id: backendRecord?.id ||
|
| 80 |
-
provider:
|
| 81 |
-
name:
|
| 82 |
-
description:
|
| 83 |
-
icon:
|
| 84 |
status: backendRecord?.status || "disconnected",
|
| 85 |
-
lastChecked: backendRecord?.last_checked_at ? new Date(backendRecord.last_checked_at).toLocaleTimeString() : undefined
|
| 86 |
};
|
| 87 |
});
|
| 88 |
-
setIntegrations(mapped as
|
| 89 |
} else {
|
| 90 |
setFetchError(res.error || "Failed to load integrations.");
|
| 91 |
-
setIntegrations(buildDefaultCards()
|
| 92 |
}
|
| 93 |
setIsLoading(false);
|
| 94 |
};
|
| 95 |
|
| 96 |
useEffect(() => {
|
| 97 |
-
fetchIntegrations();
|
| 98 |
-
}, []);
|
| 99 |
|
| 100 |
const handleConnect = async () => {
|
| 101 |
if (!configModal) return;
|
| 102 |
setIsSubmitting(true);
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
|
| 110 |
const res = await apiClient.post(`/integrations/${configModal.provider}/connect`, {
|
| 111 |
provider_workspace_id: pwid,
|
|
@@ -123,7 +117,8 @@ export default function IntegrationsPage() {
|
|
| 123 |
};
|
| 124 |
|
| 125 |
const handleDisconnect = async (provider: string) => {
|
| 126 |
-
|
|
|
|
| 127 |
setIsSubmitting(true);
|
| 128 |
const res = await apiClient.post(`/integrations/${provider}/disconnect`);
|
| 129 |
if (res.success) {
|
|
@@ -143,6 +138,8 @@ export default function IntegrationsPage() {
|
|
| 143 |
}
|
| 144 |
};
|
| 145 |
|
|
|
|
|
|
|
| 146 |
return (
|
| 147 |
<div className="flex flex-col space-y-8">
|
| 148 |
<div className="flex items-center justify-between">
|
|
@@ -175,13 +172,12 @@ export default function IntegrationsPage() {
|
|
| 175 |
</div>
|
| 176 |
) : integrations.map((item) => {
|
| 177 |
const status = getStatusInfo(item.status);
|
| 178 |
-
const metadata = PROVIDER_METADATA[item.provider];
|
| 179 |
return (
|
| 180 |
<div key={item.provider} className="bg-white border border-border rounded-xl shadow-sm hover:shadow-md transition-shadow flex flex-col">
|
| 181 |
<div className="p-6 flex-1">
|
| 182 |
<div className="flex items-start justify-between mb-4">
|
| 183 |
<div className="w-12 h-12 rounded-lg border border-slate-100 p-2 flex items-center justify-center bg-white shadow-sm">
|
| 184 |
-
<img src={
|
| 185 |
</div>
|
| 186 |
<div className={cn(
|
| 187 |
"flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold border",
|
|
@@ -192,9 +188,9 @@ export default function IntegrationsPage() {
|
|
| 192 |
</div>
|
| 193 |
</div>
|
| 194 |
|
| 195 |
-
<h3 className="font-bold text-slate-800 mb-1">{
|
| 196 |
<p className="text-xs text-slate-500 leading-relaxed min-h-[40px]">
|
| 197 |
-
{
|
| 198 |
</p>
|
| 199 |
|
| 200 |
{item.lastChecked && (
|
|
@@ -234,7 +230,7 @@ export default function IntegrationsPage() {
|
|
| 234 |
className="w-full px-3 py-2 bg-teal-600 text-white rounded-md text-xs font-bold hover:bg-teal-700 transition-colors flex items-center justify-center gap-2 shadow-sm"
|
| 235 |
>
|
| 236 |
<Plug className="w-3.5 h-3.5" />
|
| 237 |
-
Connect {
|
| 238 |
</button>
|
| 239 |
)}
|
| 240 |
</div>
|
|
@@ -243,7 +239,7 @@ export default function IntegrationsPage() {
|
|
| 243 |
})}
|
| 244 |
</div>
|
| 245 |
|
| 246 |
-
{/* Custom Integration Banner
|
| 247 |
<div className="bg-slate-50 border border-slate-200 rounded-xl p-6 flex items-center justify-between">
|
| 248 |
<div>
|
| 249 |
<h2 className="text-sm font-bold text-slate-900 mb-1">Need a custom integration?</h2>
|
|
@@ -258,20 +254,20 @@ export default function IntegrationsPage() {
|
|
| 258 |
</div>
|
| 259 |
|
| 260 |
{/* Configuration Modal */}
|
| 261 |
-
{configModal && (
|
| 262 |
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 backdrop-blur-sm p-4">
|
| 263 |
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md border border-border animate-in fade-in zoom-in-95 duration-200 overflow-hidden">
|
| 264 |
<div className="p-6 border-b border-border bg-slate-50/50">
|
| 265 |
<div className="flex items-center gap-3">
|
| 266 |
-
<img src={
|
| 267 |
<div>
|
| 268 |
-
<h3 className="font-bold text-slate-900">Connect {
|
| 269 |
<p className="text-xs text-slate-500">Enter your credentials to sync LeadPilot.</p>
|
| 270 |
</div>
|
| 271 |
</div>
|
| 272 |
</div>
|
| 273 |
<div className="p-6 space-y-4">
|
| 274 |
-
{
|
| 275 |
<div key={f.name} className="space-y-1.5">
|
| 276 |
<label className="text-xs font-bold text-slate-600 uppercase tracking-wider">{f.label}</label>
|
| 277 |
<input
|
|
|
|
| 15 |
import Link from "next/link";
|
| 16 |
import { cn } from "@/lib/utils";
|
| 17 |
import { apiClient } from "@/lib/api";
|
| 18 |
+
import { useCatalog, type CatalogEntry } from "@/lib/catalog";
|
| 19 |
|
| 20 |
type Status = "connected" | "disconnected" | "error";
|
| 21 |
|
|
|
|
| 29 |
icon: string;
|
| 30 |
}
|
| 31 |
|
| 32 |
+
// Icons are a frontend presentation concern — keyed by catalog icon_hint
|
| 33 |
+
const PROVIDER_ICONS: Record<string, string> = {
|
| 34 |
+
zoho: 'data:image/svg+xml;utf8,<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Zoho</title><path fill="%230F766E" d="M21.758 12c0 5.39-4.368 9.758-9.758 9.758S2.242 17.39 2.242 12c0-5.39 4.368-9.758 9.758-9.758S21.758 6.61 21.758 12zm-9.035-4.482H6.554v2.09l4.16 4.303H6.554v2.09h6.169V14.12h.001l-4.148-4.29h4.148v-2.09.001zM17.446 12A2.77 2.77 0 1014.675 9.23 2.772 2.772 0 0017.446 12zm0 2.08A2.77 2.77 0 1020.217 16.85 2.772 2.772 0 0017.446 14.08z"/></svg>',
|
| 35 |
+
whatsapp: "https://upload.wikimedia.org/wikipedia/commons/6/6b/WhatsApp.svg",
|
| 36 |
+
meta: "https://upload.wikimedia.org/wikipedia/commons/7/7b/Meta_Platforms_Inc._logo.svg",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
};
|
| 38 |
|
| 39 |
export default function IntegrationsPage() {
|
| 40 |
+
const { data: providers } = useCatalog("integration-providers");
|
| 41 |
const [integrations, setIntegrations] = useState<IntegrationProvider[]>([]);
|
| 42 |
const [isLoading, setIsLoading] = useState(true);
|
| 43 |
const [configModal, setConfigModal] = useState<{ provider: string } | null>(null);
|
|
|
|
| 45 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 46 |
const [fetchError, setFetchError] = useState<string | null>(null);
|
| 47 |
|
| 48 |
+
const getProviderMeta = (key: string): CatalogEntry | undefined =>
|
| 49 |
+
providers?.find((p) => p.key === key);
|
| 50 |
+
|
| 51 |
+
const getIcon = (entry: CatalogEntry): string =>
|
| 52 |
+
PROVIDER_ICONS[entry.icon_hint] || PROVIDER_ICONS[entry.key] || "";
|
| 53 |
+
|
| 54 |
+
const buildDefaultCards = (): IntegrationProvider[] =>
|
| 55 |
+
(providers || []).map((p) => ({
|
| 56 |
+
id: p.key,
|
| 57 |
+
provider: p.key,
|
| 58 |
+
name: p.label,
|
| 59 |
+
description: p.description || "",
|
| 60 |
+
icon: getIcon(p),
|
| 61 |
status: "disconnected" as Status,
|
| 62 |
+
lastChecked: undefined,
|
| 63 |
}));
|
| 64 |
|
| 65 |
const fetchIntegrations = async () => {
|
|
|
|
| 67 |
setFetchError(null);
|
| 68 |
const res = await apiClient.get<any[]>("/integrations");
|
| 69 |
if (res.success && res.data) {
|
| 70 |
+
const mapped = (providers || []).map((p) => {
|
| 71 |
+
const backendRecord = res.data?.find((r: any) => r.provider === p.key);
|
| 72 |
return {
|
| 73 |
+
id: backendRecord?.id || p.key,
|
| 74 |
+
provider: p.key,
|
| 75 |
+
name: p.label,
|
| 76 |
+
description: p.description || "",
|
| 77 |
+
icon: getIcon(p),
|
| 78 |
status: backendRecord?.status || "disconnected",
|
| 79 |
+
lastChecked: backendRecord?.last_checked_at ? new Date(backendRecord.last_checked_at).toLocaleTimeString() : undefined,
|
| 80 |
};
|
| 81 |
});
|
| 82 |
+
setIntegrations(mapped as IntegrationProvider[]);
|
| 83 |
} else {
|
| 84 |
setFetchError(res.error || "Failed to load integrations.");
|
| 85 |
+
setIntegrations(buildDefaultCards());
|
| 86 |
}
|
| 87 |
setIsLoading(false);
|
| 88 |
};
|
| 89 |
|
| 90 |
useEffect(() => {
|
| 91 |
+
if (providers) fetchIntegrations();
|
| 92 |
+
}, [providers]);
|
| 93 |
|
| 94 |
const handleConnect = async () => {
|
| 95 |
if (!configModal) return;
|
| 96 |
setIsSubmitting(true);
|
| 97 |
|
| 98 |
+
const meta = getProviderMeta(configModal.provider);
|
| 99 |
+
const fields = (meta?.fields as any[]) || [];
|
| 100 |
+
// Use the first non-access_token field as provider_workspace_id
|
| 101 |
+
const idField = fields.find((f: any) => f.name !== "access_token");
|
| 102 |
+
const pwid = idField ? configPayload[idField.name] || "" : "";
|
| 103 |
|
| 104 |
const res = await apiClient.post(`/integrations/${configModal.provider}/connect`, {
|
| 105 |
provider_workspace_id: pwid,
|
|
|
|
| 117 |
};
|
| 118 |
|
| 119 |
const handleDisconnect = async (provider: string) => {
|
| 120 |
+
const meta = getProviderMeta(provider);
|
| 121 |
+
if (!confirm(`Are you sure you want to disconnect ${meta?.label || provider}?`)) return;
|
| 122 |
setIsSubmitting(true);
|
| 123 |
const res = await apiClient.post(`/integrations/${provider}/disconnect`);
|
| 124 |
if (res.success) {
|
|
|
|
| 138 |
}
|
| 139 |
};
|
| 140 |
|
| 141 |
+
const modalMeta = configModal ? getProviderMeta(configModal.provider) : null;
|
| 142 |
+
|
| 143 |
return (
|
| 144 |
<div className="flex flex-col space-y-8">
|
| 145 |
<div className="flex items-center justify-between">
|
|
|
|
| 172 |
</div>
|
| 173 |
) : integrations.map((item) => {
|
| 174 |
const status = getStatusInfo(item.status);
|
|
|
|
| 175 |
return (
|
| 176 |
<div key={item.provider} className="bg-white border border-border rounded-xl shadow-sm hover:shadow-md transition-shadow flex flex-col">
|
| 177 |
<div className="p-6 flex-1">
|
| 178 |
<div className="flex items-start justify-between mb-4">
|
| 179 |
<div className="w-12 h-12 rounded-lg border border-slate-100 p-2 flex items-center justify-center bg-white shadow-sm">
|
| 180 |
+
<img src={item.icon} alt={item.name} className="w-8 h-8 object-contain" />
|
| 181 |
</div>
|
| 182 |
<div className={cn(
|
| 183 |
"flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold border",
|
|
|
|
| 188 |
</div>
|
| 189 |
</div>
|
| 190 |
|
| 191 |
+
<h3 className="font-bold text-slate-800 mb-1">{item.name}</h3>
|
| 192 |
<p className="text-xs text-slate-500 leading-relaxed min-h-[40px]">
|
| 193 |
+
{item.description}
|
| 194 |
</p>
|
| 195 |
|
| 196 |
{item.lastChecked && (
|
|
|
|
| 230 |
className="w-full px-3 py-2 bg-teal-600 text-white rounded-md text-xs font-bold hover:bg-teal-700 transition-colors flex items-center justify-center gap-2 shadow-sm"
|
| 231 |
>
|
| 232 |
<Plug className="w-3.5 h-3.5" />
|
| 233 |
+
Connect {item.name}
|
| 234 |
</button>
|
| 235 |
)}
|
| 236 |
</div>
|
|
|
|
| 239 |
})}
|
| 240 |
</div>
|
| 241 |
|
| 242 |
+
{/* Custom Integration Banner */}
|
| 243 |
<div className="bg-slate-50 border border-slate-200 rounded-xl p-6 flex items-center justify-between">
|
| 244 |
<div>
|
| 245 |
<h2 className="text-sm font-bold text-slate-900 mb-1">Need a custom integration?</h2>
|
|
|
|
| 254 |
</div>
|
| 255 |
|
| 256 |
{/* Configuration Modal */}
|
| 257 |
+
{configModal && modalMeta && (
|
| 258 |
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 backdrop-blur-sm p-4">
|
| 259 |
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md border border-border animate-in fade-in zoom-in-95 duration-200 overflow-hidden">
|
| 260 |
<div className="p-6 border-b border-border bg-slate-50/50">
|
| 261 |
<div className="flex items-center gap-3">
|
| 262 |
+
<img src={getIcon(modalMeta)} className="w-8 h-8" />
|
| 263 |
<div>
|
| 264 |
+
<h3 className="font-bold text-slate-900">Connect {modalMeta.label}</h3>
|
| 265 |
<p className="text-xs text-slate-500">Enter your credentials to sync LeadPilot.</p>
|
| 266 |
</div>
|
| 267 |
</div>
|
| 268 |
</div>
|
| 269 |
<div className="p-6 space-y-4">
|
| 270 |
+
{(modalMeta.fields as any[] || []).map((f: any) => (
|
| 271 |
<div key={f.name} className="space-y-1.5">
|
| 272 |
<label className="text-xs font-bold text-slate-600 uppercase tracking-wider">{f.label}</label>
|
| 273 |
<input
|
frontend/src/app/(dashboard)/prompt-studio/page.tsx
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
| 7 |
Sparkles,
|
| 8 |
AlertCircle,
|
| 9 |
CheckCircle2,
|
| 10 |
-
ChevronRight,
|
| 11 |
Shield,
|
| 12 |
Target,
|
| 13 |
UserCircle,
|
|
@@ -17,7 +16,10 @@ import {
|
|
| 17 |
FileText,
|
| 18 |
Trash2,
|
| 19 |
Plus,
|
| 20 |
-
Upload
|
|
|
|
|
|
|
|
|
|
| 21 |
} from "lucide-react";
|
| 22 |
import { cn } from "@/lib/utils";
|
| 23 |
import { apiClient } from "@/lib/api";
|
|
@@ -32,6 +34,12 @@ export default function PromptStudioPage() {
|
|
| 32 |
const [knowledgeFiles, setKnowledgeFiles] = useState<any[]>([]);
|
| 33 |
const [isUploading, setIsUploading] = useState(false);
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
// Form State
|
| 36 |
const [formData, setFormData] = useState({
|
| 37 |
basics: {
|
|
@@ -70,6 +78,68 @@ export default function PromptStudioPage() {
|
|
| 70 |
}
|
| 71 |
};
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
const fetchConfig = async () => {
|
| 74 |
setIsLoading(true);
|
| 75 |
setError(null);
|
|
@@ -87,6 +157,7 @@ export default function PromptStudioPage() {
|
|
| 87 |
setError(res.error || "Failed to load prompt configuration.");
|
| 88 |
}
|
| 89 |
await fetchKnowledgeFiles();
|
|
|
|
| 90 |
} catch (err: any) {
|
| 91 |
setError("Connectivity error. Please check your internet or try again.");
|
| 92 |
} finally {
|
|
@@ -163,7 +234,8 @@ export default function PromptStudioPage() {
|
|
| 163 |
const tabs = [
|
| 164 |
{ id: "editor", label: "Assistant Configuration", icon: UserCircle },
|
| 165 |
{ id: "knowledge", label: "Knowledge Base", icon: FileText },
|
| 166 |
-
{ id: "
|
|
|
|
| 167 |
];
|
| 168 |
|
| 169 |
return (
|
|
@@ -225,6 +297,11 @@ export default function PromptStudioPage() {
|
|
| 225 |
{knowledgeFiles.length}
|
| 226 |
</span>
|
| 227 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
</button>
|
| 229 |
))}
|
| 230 |
</div>
|
|
@@ -449,15 +526,43 @@ export default function PromptStudioPage() {
|
|
| 449 |
<span>{(file.size_bytes / 1024).toFixed(1)} KB</span>
|
| 450 |
<span>•</span>
|
| 451 |
<span>{new Date(file.created_at).toLocaleDateString()}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
</div>
|
| 453 |
</div>
|
| 454 |
</div>
|
| 455 |
-
<
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
</div>
|
| 462 |
))
|
| 463 |
)}
|
|
@@ -466,7 +571,169 @@ export default function PromptStudioPage() {
|
|
| 466 |
<div className="bg-amber-50 border border-amber-100 rounded-xl p-4 flex gap-3">
|
| 467 |
<AlertCircle className="w-4 h-4 text-amber-600 shrink-0" />
|
| 468 |
<p className="text-[11px] text-amber-800 leading-normal">
|
| 469 |
-
<strong>Pro Tip:</strong>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
</p>
|
| 471 |
</div>
|
| 472 |
</div>
|
|
|
|
| 7 |
Sparkles,
|
| 8 |
AlertCircle,
|
| 9 |
CheckCircle2,
|
|
|
|
| 10 |
Shield,
|
| 11 |
Target,
|
| 12 |
UserCircle,
|
|
|
|
| 16 |
FileText,
|
| 17 |
Trash2,
|
| 18 |
Plus,
|
| 19 |
+
Upload,
|
| 20 |
+
Download,
|
| 21 |
+
ArrowUp,
|
| 22 |
+
ArrowDown,
|
| 23 |
} from "lucide-react";
|
| 24 |
import { cn } from "@/lib/utils";
|
| 25 |
import { apiClient } from "@/lib/api";
|
|
|
|
| 34 |
const [knowledgeFiles, setKnowledgeFiles] = useState<any[]>([]);
|
| 35 |
const [isUploading, setIsUploading] = useState(false);
|
| 36 |
|
| 37 |
+
// Qualification state
|
| 38 |
+
const [qualQuestions, setQualQuestions] = useState<any[]>([]);
|
| 39 |
+
const [qualStatuses, setQualStatuses] = useState<any[]>([]);
|
| 40 |
+
const [qualVersion, setQualVersion] = useState(1);
|
| 41 |
+
const [isSavingQual, setIsSavingQual] = useState(false);
|
| 42 |
+
|
| 43 |
// Form State
|
| 44 |
const [formData, setFormData] = useState({
|
| 45 |
basics: {
|
|
|
|
| 78 |
}
|
| 79 |
};
|
| 80 |
|
| 81 |
+
const fetchQualificationConfig = async () => {
|
| 82 |
+
const res = await apiClient.get("/qualification-config");
|
| 83 |
+
if (res.success && res.data) {
|
| 84 |
+
setQualQuestions(res.data.qualification_questions || []);
|
| 85 |
+
setQualStatuses(res.data.qualification_statuses || []);
|
| 86 |
+
setQualVersion(res.data.version || 1);
|
| 87 |
+
}
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
const handleSaveQualification = async () => {
|
| 91 |
+
setIsSavingQual(true);
|
| 92 |
+
const res = await apiClient.post("/qualification-config", {
|
| 93 |
+
qualification_questions: qualQuestions,
|
| 94 |
+
qualification_statuses: qualStatuses,
|
| 95 |
+
});
|
| 96 |
+
if (res.success && res.data) {
|
| 97 |
+
setQualVersion(res.data.version || qualVersion + 1);
|
| 98 |
+
} else {
|
| 99 |
+
alert("Error saving qualification config: " + res.error);
|
| 100 |
+
}
|
| 101 |
+
setIsSavingQual(false);
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
const addQuestion = () => {
|
| 105 |
+
setQualQuestions([
|
| 106 |
+
...qualQuestions,
|
| 107 |
+
{ label: "", enabled: true, order: qualQuestions.length },
|
| 108 |
+
]);
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const removeQuestion = (index: number) => {
|
| 112 |
+
setQualQuestions(qualQuestions.filter((_, i) => i !== index));
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const updateQuestion = (index: number, field: string, value: any) => {
|
| 116 |
+
setQualQuestions(qualQuestions.map((q, i) => i === index ? { ...q, [field]: value } : q));
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
const moveQuestion = (index: number, direction: "up" | "down") => {
|
| 120 |
+
const target = direction === "up" ? index - 1 : index + 1;
|
| 121 |
+
if (target < 0 || target >= qualQuestions.length) return;
|
| 122 |
+
const next = [...qualQuestions];
|
| 123 |
+
[next[index], next[target]] = [next[target], next[index]];
|
| 124 |
+
next.forEach((q, i) => (q.order = i));
|
| 125 |
+
setQualQuestions(next);
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const addStatus = () => {
|
| 129 |
+
setQualStatuses([
|
| 130 |
+
...qualStatuses,
|
| 131 |
+
{ label: "", color: "#94a3b8", enabled: true },
|
| 132 |
+
]);
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
const removeStatus = (index: number) => {
|
| 136 |
+
setQualStatuses(qualStatuses.filter((_, i) => i !== index));
|
| 137 |
+
};
|
| 138 |
+
|
| 139 |
+
const updateStatus = (index: number, field: string, value: any) => {
|
| 140 |
+
setQualStatuses(qualStatuses.map((s, i) => i === index ? { ...s, [field]: value } : s));
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
const fetchConfig = async () => {
|
| 144 |
setIsLoading(true);
|
| 145 |
setError(null);
|
|
|
|
| 157 |
setError(res.error || "Failed to load prompt configuration.");
|
| 158 |
}
|
| 159 |
await fetchKnowledgeFiles();
|
| 160 |
+
await fetchQualificationConfig();
|
| 161 |
} catch (err: any) {
|
| 162 |
setError("Connectivity error. Please check your internet or try again.");
|
| 163 |
} finally {
|
|
|
|
| 234 |
const tabs = [
|
| 235 |
{ id: "editor", label: "Assistant Configuration", icon: UserCircle },
|
| 236 |
{ id: "knowledge", label: "Knowledge Base", icon: FileText },
|
| 237 |
+
{ id: "qualification", label: "Lead Qualification", icon: Target },
|
| 238 |
+
{ id: "history", label: "Version History", icon: History },
|
| 239 |
];
|
| 240 |
|
| 241 |
return (
|
|
|
|
| 297 |
{knowledgeFiles.length}
|
| 298 |
</span>
|
| 299 |
)}
|
| 300 |
+
{tab.id === "qualification" && qualQuestions.filter(q => q.enabled !== false).length > 0 && (
|
| 301 |
+
<span className="ml-2 bg-slate-200 text-slate-600 px-1.5 py-0.5 rounded-full text-[10px]">
|
| 302 |
+
{qualQuestions.filter(q => q.enabled !== false).length}
|
| 303 |
+
</span>
|
| 304 |
+
)}
|
| 305 |
</button>
|
| 306 |
))}
|
| 307 |
</div>
|
|
|
|
| 526 |
<span>{(file.size_bytes / 1024).toFixed(1)} KB</span>
|
| 527 |
<span>•</span>
|
| 528 |
<span>{new Date(file.created_at).toLocaleDateString()}</span>
|
| 529 |
+
<span>•</span>
|
| 530 |
+
{file.status === "READY" ? (
|
| 531 |
+
<span className="text-emerald-600 flex items-center gap-0.5">
|
| 532 |
+
<CheckCircle2 className="w-3 h-3" /> Indexed
|
| 533 |
+
</span>
|
| 534 |
+
) : (
|
| 535 |
+
<span className="text-rose-500 flex items-center gap-0.5">
|
| 536 |
+
<AlertCircle className="w-3 h-3" /> Failed
|
| 537 |
+
</span>
|
| 538 |
+
)}
|
| 539 |
+
{file.extracted && (
|
| 540 |
+
<>
|
| 541 |
+
<span>•</span>
|
| 542 |
+
<span className="text-teal-600">Text extracted</span>
|
| 543 |
+
</>
|
| 544 |
+
)}
|
| 545 |
</div>
|
| 546 |
</div>
|
| 547 |
</div>
|
| 548 |
+
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all">
|
| 549 |
+
<a
|
| 550 |
+
href={`${process.env.NEXT_PUBLIC_API_URL || ""}/api/v1/knowledge/files/${file.id}/download`}
|
| 551 |
+
target="_blank"
|
| 552 |
+
rel="noopener noreferrer"
|
| 553 |
+
className="p-2 text-slate-300 hover:text-teal-600 hover:bg-teal-50 rounded-lg transition-all"
|
| 554 |
+
title="Download"
|
| 555 |
+
>
|
| 556 |
+
<Download className="w-4 h-4" />
|
| 557 |
+
</a>
|
| 558 |
+
<button
|
| 559 |
+
onClick={() => handleDeleteFile(file.id)}
|
| 560 |
+
className="p-2 text-slate-300 hover:text-rose-500 hover:bg-rose-50 rounded-lg transition-all"
|
| 561 |
+
title="Delete"
|
| 562 |
+
>
|
| 563 |
+
<Trash2 className="w-4 h-4" />
|
| 564 |
+
</button>
|
| 565 |
+
</div>
|
| 566 |
</div>
|
| 567 |
))
|
| 568 |
)}
|
|
|
|
| 571 |
<div className="bg-amber-50 border border-amber-100 rounded-xl p-4 flex gap-3">
|
| 572 |
<AlertCircle className="w-4 h-4 text-amber-600 shrink-0" />
|
| 573 |
<p className="text-[11px] text-amber-800 leading-normal">
|
| 574 |
+
<strong>Pro Tip:</strong> Uploaded files (TXT, CSV, PDF) are automatically extracted and injected into your AI's context. The AI will reference these documents when responding to leads.
|
| 575 |
+
</p>
|
| 576 |
+
</div>
|
| 577 |
+
</div>
|
| 578 |
+
) : activeTab === "qualification" ? (
|
| 579 |
+
<div className="space-y-8">
|
| 580 |
+
{/* Header */}
|
| 581 |
+
<div className="flex items-center justify-between border-b border-slate-100 pb-4">
|
| 582 |
+
<div>
|
| 583 |
+
<h2 className="text-lg font-bold text-slate-800">Lead Qualification</h2>
|
| 584 |
+
<p className="text-xs text-slate-500">Configure questions the AI collects from leads, and qualification statuses. <span className="text-slate-400">(v{qualVersion})</span></p>
|
| 585 |
+
</div>
|
| 586 |
+
<button
|
| 587 |
+
onClick={handleSaveQualification}
|
| 588 |
+
disabled={isSavingQual}
|
| 589 |
+
className="bg-teal-700 text-white px-5 py-2 rounded-lg text-sm font-bold hover:bg-teal-800 transition-all flex items-center gap-2 shadow-sm disabled:opacity-50"
|
| 590 |
+
>
|
| 591 |
+
{isSavingQual ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
| 592 |
+
Save Qualification
|
| 593 |
+
</button>
|
| 594 |
+
</div>
|
| 595 |
+
|
| 596 |
+
{/* Questions Section */}
|
| 597 |
+
<section className="space-y-4">
|
| 598 |
+
<div className="flex items-center justify-between">
|
| 599 |
+
<div className="flex items-center gap-2 text-slate-800">
|
| 600 |
+
<Target className="w-4 h-4 text-teal-600" />
|
| 601 |
+
<h3 className="text-sm font-bold uppercase tracking-wider">Qualification Questions</h3>
|
| 602 |
+
</div>
|
| 603 |
+
<button
|
| 604 |
+
onClick={addQuestion}
|
| 605 |
+
className="text-xs font-bold text-teal-600 hover:text-teal-700 flex items-center gap-1 transition-all"
|
| 606 |
+
>
|
| 607 |
+
<Plus className="w-3.5 h-3.5" /> Add Question
|
| 608 |
+
</button>
|
| 609 |
+
</div>
|
| 610 |
+
<p className="text-[11px] text-slate-400">The AI will attempt to collect these details from each lead during conversation.</p>
|
| 611 |
+
|
| 612 |
+
{qualQuestions.length === 0 ? (
|
| 613 |
+
<div className="py-10 flex flex-col items-center justify-center border-2 border-dashed border-slate-200 rounded-xl bg-slate-50 text-slate-400">
|
| 614 |
+
<Target className="w-8 h-8 mb-3 opacity-20" />
|
| 615 |
+
<p className="text-sm font-medium">No questions configured</p>
|
| 616 |
+
<p className="text-[10px] mt-1">Click "Add Question" to start building your qualification form.</p>
|
| 617 |
+
</div>
|
| 618 |
+
) : (
|
| 619 |
+
<div className="space-y-2">
|
| 620 |
+
{qualQuestions.map((q, i) => (
|
| 621 |
+
<div key={i} className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-xl group hover:border-teal-400 transition-all">
|
| 622 |
+
<div className="flex flex-col gap-0.5">
|
| 623 |
+
<button
|
| 624 |
+
onClick={() => moveQuestion(i, "up")}
|
| 625 |
+
disabled={i === 0}
|
| 626 |
+
className="p-0.5 text-slate-300 hover:text-slate-600 disabled:opacity-20 transition-all"
|
| 627 |
+
>
|
| 628 |
+
<ArrowUp className="w-3 h-3" />
|
| 629 |
+
</button>
|
| 630 |
+
<button
|
| 631 |
+
onClick={() => moveQuestion(i, "down")}
|
| 632 |
+
disabled={i === qualQuestions.length - 1}
|
| 633 |
+
className="p-0.5 text-slate-300 hover:text-slate-600 disabled:opacity-20 transition-all"
|
| 634 |
+
>
|
| 635 |
+
<ArrowDown className="w-3 h-3" />
|
| 636 |
+
</button>
|
| 637 |
+
</div>
|
| 638 |
+
<input
|
| 639 |
+
type="text"
|
| 640 |
+
value={q.label}
|
| 641 |
+
onChange={(e) => updateQuestion(i, "label", e.target.value)}
|
| 642 |
+
placeholder="e.g. Budget Range"
|
| 643 |
+
className="flex-1 p-2 bg-slate-50 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-teal-500/20 focus:border-teal-500 outline-none transition-all"
|
| 644 |
+
/>
|
| 645 |
+
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
| 646 |
+
<input
|
| 647 |
+
type="checkbox"
|
| 648 |
+
checked={q.enabled !== false}
|
| 649 |
+
onChange={(e) => updateQuestion(i, "enabled", e.target.checked)}
|
| 650 |
+
className="w-4 h-4 accent-teal-600"
|
| 651 |
+
/>
|
| 652 |
+
<span className="text-[10px] font-medium text-slate-500">Enabled</span>
|
| 653 |
+
</label>
|
| 654 |
+
<button
|
| 655 |
+
onClick={() => removeQuestion(i)}
|
| 656 |
+
className="p-1.5 text-slate-300 hover:text-rose-500 hover:bg-rose-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
|
| 657 |
+
>
|
| 658 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 659 |
+
</button>
|
| 660 |
+
</div>
|
| 661 |
+
))}
|
| 662 |
+
</div>
|
| 663 |
+
)}
|
| 664 |
+
</section>
|
| 665 |
+
|
| 666 |
+
{/* Statuses Section */}
|
| 667 |
+
<section className="space-y-4">
|
| 668 |
+
<div className="flex items-center justify-between">
|
| 669 |
+
<div className="flex items-center gap-2 text-slate-800">
|
| 670 |
+
<Shield className="w-4 h-4 text-teal-600" />
|
| 671 |
+
<h3 className="text-sm font-bold uppercase tracking-wider">Qualification Statuses</h3>
|
| 672 |
+
</div>
|
| 673 |
+
<button
|
| 674 |
+
onClick={addStatus}
|
| 675 |
+
className="text-xs font-bold text-teal-600 hover:text-teal-700 flex items-center gap-1 transition-all"
|
| 676 |
+
>
|
| 677 |
+
<Plus className="w-3.5 h-3.5" /> Add Status
|
| 678 |
+
</button>
|
| 679 |
+
</div>
|
| 680 |
+
<p className="text-[11px] text-slate-400">Define the lifecycle stages for your leads. These statuses will appear in the Contacts page.</p>
|
| 681 |
+
|
| 682 |
+
{qualStatuses.length === 0 ? (
|
| 683 |
+
<div className="py-10 flex flex-col items-center justify-center border-2 border-dashed border-slate-200 rounded-xl bg-slate-50 text-slate-400">
|
| 684 |
+
<Shield className="w-8 h-8 mb-3 opacity-20" />
|
| 685 |
+
<p className="text-sm font-medium">No statuses configured</p>
|
| 686 |
+
<p className="text-[10px] mt-1">Click "Add Status" to define lead lifecycle stages.</p>
|
| 687 |
+
</div>
|
| 688 |
+
) : (
|
| 689 |
+
<div className="space-y-2">
|
| 690 |
+
{qualStatuses.map((s, i) => (
|
| 691 |
+
<div key={i} className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-xl group hover:border-teal-400 transition-all">
|
| 692 |
+
<input
|
| 693 |
+
type="color"
|
| 694 |
+
value={s.color || "#94a3b8"}
|
| 695 |
+
onChange={(e) => updateStatus(i, "color", e.target.value)}
|
| 696 |
+
className="w-8 h-8 rounded-lg border border-slate-200 cursor-pointer p-0.5"
|
| 697 |
+
title="Pick color"
|
| 698 |
+
/>
|
| 699 |
+
<input
|
| 700 |
+
type="text"
|
| 701 |
+
value={s.label}
|
| 702 |
+
onChange={(e) => updateStatus(i, "label", e.target.value)}
|
| 703 |
+
placeholder="e.g. Qualified"
|
| 704 |
+
className="flex-1 p-2 bg-slate-50 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-teal-500/20 focus:border-teal-500 outline-none transition-all"
|
| 705 |
+
/>
|
| 706 |
+
<span
|
| 707 |
+
className="px-3 py-1 rounded-full text-[10px] font-bold text-white"
|
| 708 |
+
style={{ backgroundColor: s.color || "#94a3b8" }}
|
| 709 |
+
>
|
| 710 |
+
{s.label || "Preview"}
|
| 711 |
+
</span>
|
| 712 |
+
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
| 713 |
+
<input
|
| 714 |
+
type="checkbox"
|
| 715 |
+
checked={s.enabled !== false}
|
| 716 |
+
onChange={(e) => updateStatus(i, "enabled", e.target.checked)}
|
| 717 |
+
className="w-4 h-4 accent-teal-600"
|
| 718 |
+
/>
|
| 719 |
+
<span className="text-[10px] font-medium text-slate-500">Enabled</span>
|
| 720 |
+
</label>
|
| 721 |
+
<button
|
| 722 |
+
onClick={() => removeStatus(i)}
|
| 723 |
+
className="p-1.5 text-slate-300 hover:text-rose-500 hover:bg-rose-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
|
| 724 |
+
>
|
| 725 |
+
<Trash2 className="w-3.5 h-3.5" />
|
| 726 |
+
</button>
|
| 727 |
+
</div>
|
| 728 |
+
))}
|
| 729 |
+
</div>
|
| 730 |
+
)}
|
| 731 |
+
</section>
|
| 732 |
+
|
| 733 |
+
<div className="bg-teal-50/50 border border-teal-100 rounded-xl p-4 flex gap-3">
|
| 734 |
+
<AlertCircle className="w-4 h-4 text-teal-600 shrink-0" />
|
| 735 |
+
<p className="text-[11px] text-teal-700 leading-normal">
|
| 736 |
+
<strong>How it works:</strong> Enabled questions are injected into the AI's system prompt. The AI will naturally ask leads for this information during conversation. Statuses can be used to categorize leads in the Contacts page.
|
| 737 |
</p>
|
| 738 |
</div>
|
| 739 |
</div>
|
frontend/src/app/(marketing)/plans/page.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { Check, Clock, ChevronDown, ChevronUp, Loader2, CheckCircle, AlertCircle
|
|
| 5 |
import Link from "next/link";
|
| 6 |
import AnimateOnScroll from "@/components/marketing/AnimateOnScroll";
|
| 7 |
import FloatingOrb from "@/components/marketing/FloatingOrb";
|
|
|
|
| 8 |
|
| 9 |
const tiers = [
|
| 10 |
{
|
|
@@ -42,6 +43,8 @@ const faqs = [
|
|
| 42 |
];
|
| 43 |
|
| 44 |
function WaitlistForm() {
|
|
|
|
|
|
|
| 45 |
const [email, setEmail] = useState("");
|
| 46 |
const [plan, setPlan] = useState("Growth");
|
| 47 |
const [state, setState] = useState<"idle" | "loading" | "success" | "error">("idle");
|
|
@@ -94,9 +97,16 @@ function WaitlistForm() {
|
|
| 94 |
className="w-full sm:w-36 px-4 py-3 rounded-xl text-sm"
|
| 95 |
style={{ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "#F1F5F9", outline: "none" }}
|
| 96 |
>
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
</select>
|
| 101 |
</div>
|
| 102 |
<button type="submit" disabled={state === "loading"}
|
|
|
|
| 5 |
import Link from "next/link";
|
| 6 |
import AnimateOnScroll from "@/components/marketing/AnimateOnScroll";
|
| 7 |
import FloatingOrb from "@/components/marketing/FloatingOrb";
|
| 8 |
+
import { useCatalog, type PlanCatalogEntry } from "@/lib/catalog";
|
| 9 |
|
| 10 |
const tiers = [
|
| 11 |
{
|
|
|
|
| 43 |
];
|
| 44 |
|
| 45 |
function WaitlistForm() {
|
| 46 |
+
const { data: plans } = useCatalog<PlanCatalogEntry[]>("plans");
|
| 47 |
+
const waitlistPlans = (plans || []).filter(p => p.name !== "free");
|
| 48 |
const [email, setEmail] = useState("");
|
| 49 |
const [plan, setPlan] = useState("Growth");
|
| 50 |
const [state, setState] = useState<"idle" | "loading" | "success" | "error">("idle");
|
|
|
|
| 97 |
className="w-full sm:w-36 px-4 py-3 rounded-xl text-sm"
|
| 98 |
style={{ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "#F1F5F9", outline: "none" }}
|
| 99 |
>
|
| 100 |
+
{waitlistPlans.length > 0
|
| 101 |
+
? waitlistPlans.map((p) => (
|
| 102 |
+
<option key={p.name} value={p.display_name}>{p.display_name}</option>
|
| 103 |
+
))
|
| 104 |
+
: <>
|
| 105 |
+
<option value="Growth">Growth</option>
|
| 106 |
+
<option value="Pro">Pro</option>
|
| 107 |
+
<option value="Enterprise">Enterprise</option>
|
| 108 |
+
</>
|
| 109 |
+
}
|
| 110 |
</select>
|
| 111 |
</div>
|
| 112 |
<button type="submit" disabled={state === "loading"}
|
frontend/src/lib/admin-api.ts
CHANGED
|
@@ -325,6 +325,7 @@ export const adminApi = {
|
|
| 325 |
adminClient.put(`/admin/agencies/${agencyId}/plan`, { plan_id: planId }),
|
| 326 |
};
|
| 327 |
|
|
|
|
| 328 |
export const MODULE_LABELS: Record<string, string> = {
|
| 329 |
auth: "Authentication",
|
| 330 |
email_engine: "Email Engine",
|
|
|
|
| 325 |
adminClient.put(`/admin/agencies/${agencyId}/plan`, { plan_id: planId }),
|
| 326 |
};
|
| 327 |
|
| 328 |
+
// Canonical source: GET /api/v1/catalog/modules — prefer useCatalog("modules") for dynamic labels
|
| 329 |
export const MODULE_LABELS: Record<string, string> = {
|
| 330 |
auth: "Authentication",
|
| 331 |
email_engine: "Email Engine",
|
frontend/src/lib/catalog.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Catalog client — Mission 19
|
| 3 |
+
* Provides a useCatalog() hook that fetches and caches catalog data.
|
| 4 |
+
* Uses the product apiClient (no auth required for catalog endpoints).
|
| 5 |
+
*/
|
| 6 |
+
import { useState, useEffect, useCallback } from "react";
|
| 7 |
+
import { apiClient, type ApiResponse } from "./api";
|
| 8 |
+
|
| 9 |
+
// ── Types ────────────────────────────────────────────────────────────
|
| 10 |
+
|
| 11 |
+
export interface CatalogEntry {
|
| 12 |
+
key: string;
|
| 13 |
+
label: string;
|
| 14 |
+
description?: string;
|
| 15 |
+
[extra: string]: any;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export interface PlanCatalogEntry {
|
| 19 |
+
id: string;
|
| 20 |
+
name: string;
|
| 21 |
+
display_name: string;
|
| 22 |
+
description: string | null;
|
| 23 |
+
sort_order: number;
|
| 24 |
+
entitlements: { module_key: string; hard_limit: number | null }[];
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
export type CatalogKey =
|
| 28 |
+
| "plans"
|
| 29 |
+
| "tiers"
|
| 30 |
+
| "modules"
|
| 31 |
+
| "workspace-roles"
|
| 32 |
+
| "admin-roles"
|
| 33 |
+
| "integration-providers"
|
| 34 |
+
| "automation-node-types"
|
| 35 |
+
| "automation-trigger-types"
|
| 36 |
+
| "conversation-statuses"
|
| 37 |
+
| "message-delivery-statuses";
|
| 38 |
+
|
| 39 |
+
// ── Cache ────────────────────────────────────────────────────────────
|
| 40 |
+
|
| 41 |
+
interface CacheEntry<T> {
|
| 42 |
+
data: T;
|
| 43 |
+
timestamp: number;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const CACHE_TTL_MS = 60_000;
|
| 47 |
+
const cache = new Map<string, CacheEntry<unknown>>();
|
| 48 |
+
|
| 49 |
+
function getCached<T>(key: string): T | null {
|
| 50 |
+
const entry = cache.get(key);
|
| 51 |
+
if (!entry) return null;
|
| 52 |
+
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
|
| 53 |
+
cache.delete(key);
|
| 54 |
+
return null;
|
| 55 |
+
}
|
| 56 |
+
return entry.data as T;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function setCache<T>(key: string, data: T): void {
|
| 60 |
+
cache.set(key, { data, timestamp: Date.now() });
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// ── Fetch ────────────────────────────────────────────────────────────
|
| 64 |
+
|
| 65 |
+
async function fetchCatalog<T = CatalogEntry[]>(key: CatalogKey): Promise<T> {
|
| 66 |
+
const cached = getCached<T>(key);
|
| 67 |
+
if (cached !== null) return cached;
|
| 68 |
+
|
| 69 |
+
const res: ApiResponse<T> = await apiClient.get<T>(`/catalog/${key}`);
|
| 70 |
+
if (res.success && res.data) {
|
| 71 |
+
setCache(key, res.data);
|
| 72 |
+
return res.data;
|
| 73 |
+
}
|
| 74 |
+
throw new Error(res.error || `Failed to fetch catalog: ${key}`);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// ── Hook ─────────────────────────────────────────────────────────────
|
| 78 |
+
|
| 79 |
+
export interface UseCatalogResult<T> {
|
| 80 |
+
data: T | null;
|
| 81 |
+
loading: boolean;
|
| 82 |
+
error: string | null;
|
| 83 |
+
refetch: () => void;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
export function useCatalog<T = CatalogEntry[]>(
|
| 87 |
+
key: CatalogKey
|
| 88 |
+
): UseCatalogResult<T> {
|
| 89 |
+
const [data, setData] = useState<T | null>(() => getCached<T>(key));
|
| 90 |
+
const [loading, setLoading] = useState<boolean>(data === null);
|
| 91 |
+
const [error, setError] = useState<string | null>(null);
|
| 92 |
+
|
| 93 |
+
const load = useCallback(() => {
|
| 94 |
+
setLoading(true);
|
| 95 |
+
setError(null);
|
| 96 |
+
fetchCatalog<T>(key)
|
| 97 |
+
.then((result) => {
|
| 98 |
+
setData(result);
|
| 99 |
+
setLoading(false);
|
| 100 |
+
})
|
| 101 |
+
.catch((err) => {
|
| 102 |
+
setError(err.message || "Catalog fetch failed");
|
| 103 |
+
setLoading(false);
|
| 104 |
+
});
|
| 105 |
+
}, [key]);
|
| 106 |
+
|
| 107 |
+
useEffect(() => {
|
| 108 |
+
if (data !== null) {
|
| 109 |
+
setLoading(false);
|
| 110 |
+
return;
|
| 111 |
+
}
|
| 112 |
+
load();
|
| 113 |
+
}, [key, load, data]);
|
| 114 |
+
|
| 115 |
+
return { data, loading, error, refetch: load };
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// ── Helpers ──────────────────────────────────────────────────────────
|
| 119 |
+
|
| 120 |
+
export function catalogLookup(
|
| 121 |
+
items: CatalogEntry[] | null,
|
| 122 |
+
key: string
|
| 123 |
+
): CatalogEntry | undefined {
|
| 124 |
+
return items?.find((item) => item.key === key);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
export function catalogLabel(
|
| 128 |
+
items: CatalogEntry[] | null,
|
| 129 |
+
key: string,
|
| 130 |
+
fallback?: string
|
| 131 |
+
): string {
|
| 132 |
+
return catalogLookup(items, key)?.label ?? fallback ?? key;
|
| 133 |
+
}
|