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 files

Mission 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 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 ["zoho", "whatsapp", "meta"]:
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
- if not os.path.exists(STORAGE_DIR):
29
- os.makedirs(STORAGE_DIR, exist_ok=True)
30
 
31
  file_id = str(uuid.uuid4())
32
- extension = os.path.splitext(file.filename)[1]
33
- storage_path = os.path.join(STORAGE_DIR, f"{file_id}{extension}")
34
 
35
- # Save file
36
  try:
 
 
37
  with open(storage_path, "wb") as buffer:
38
- shutil.copyfileobj(file.file, buffer)
39
  except Exception as e:
40
  return wrap_error(f"Failed to save file: {str(e)}")
41
 
42
- # Extract text for MVP (txt, md, json, csv)
 
 
 
 
 
 
 
 
 
 
 
 
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
- pass # Extraction failed, keep as None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "created_at": f.created_at.isoformat()
 
 
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 List, Any, Dict
2
- from pydantic import BaseModel
3
  from uuid import UUID
4
- from fastapi import APIRouter, Depends, HTTPException, status
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], # {"text": "..."}
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 active PromptConfig."""
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. Get active prompt config
101
- result = await db.execute(
102
- select(PromptConfig).where(PromptConfig.workspace_id == workspace.id)
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
- result = await db.execute(
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
- # 6. Generate AI Reply
134
  reply_text = await ai_provider.generate_chat_reply(
135
- prompt=system_prompt,
136
  history=history,
137
- temperature=version.temperature,
138
- max_tokens=version.max_tokens_per_execution
139
  )
140
 
141
- # 7. Store assistant message
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
- PromptConfig,
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 PromptConfig and Conversation History."""
287
- # 1. Fetch Prompt Config
288
- prompt_config_result = await session.execute(
289
- select(PromptConfig).where(PromptConfig.workspace_id == instance.workspace_id)
 
 
 
 
290
  )
291
- prompt_config = prompt_config_result.scalars().first()
292
- if not prompt_config or not prompt_config.current_version_id:
293
  raise Exception("No active PromptConfig found for workspace")
294
-
295
- prompt_version = await session.get(PromptVersion, prompt_config.current_version_id)
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
- # System prompt
317
- messages.append({"role": "system", "content": f"{prompt_version.system_prompt_text}\n\nBusiness Context: {prompt_version.business_profile_json}"})
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 = prompt_version.temperature if prompt_version.temperature != 0.7 else ws_ai.get("temperature", 0.7)
328
- effective_max_tokens = prompt_version.max_tokens_per_execution if prompt_version.max_tokens_per_execution != 1000 else ws_ai.get("max_tokens", 2048)
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": "WEBHOOK", "platform": "WHATSAPP", "keywords": []},
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
- {STATUS_OPTIONS.map((s) => (
86
- <option key={s} value={s} className="bg-slate-900">{s || "All Statuses"}</option>
 
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.type, platform: t.id === "whatsapp" ? "whatsapp" : "meta" })}
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.type && (t.id === "whatsapp" ? trigger.platform === "whatsapp" : trigger.platform === "meta"))
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
- { type: "AI_REPLY", name: "AI Reply", icon: Bot },
231
- { type: "SEND_MESSAGE", name: "Send Message", icon: Send },
232
- { type: "HUMAN_HANDOVER", name: "Human Handover", icon: UserRound },
233
- { type: "TAG_CONTACT", name: "Tag Contact", icon: TagIcon }
234
- ].map(action => (
235
- <button
236
- key={action.type}
237
- onClick={() => addStep(action.type as StepType)}
238
- 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"
239
- >
240
- <action.icon className="w-4 h-4" />
241
- {action.name}
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
- {step.type === "AI_REPLY" && <Bot className="w-4 h-4 text-teal-600" />}
269
- {step.type === "SEND_MESSAGE" && <Send className="w-4 h-4 text-teal-600" />}
270
- {step.type === "HUMAN_HANDOVER" && <UserRound className="w-4 h-4 text-teal-600" />}
271
- {step.type === "TAG_CONTACT" && <TagIcon className="w-4 h-4 text-teal-600" />}
272
- {step.type.replace("_", " ")}
 
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" /> Sent
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" /> Failed
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" /> Sending
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
- <option value="bot_active">AI Active</option>
191
- <option value="human_takeover">Human</option>
192
- <option value="closed">Closed</option>
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
- switch (status) {
402
- case "bot_active":
403
- return <div className="flex items-center gap-1.5 px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-full text-[10px] font-bold border border-emerald-200 uppercase">AI Active</div>;
404
- case "human_takeover":
405
- return <div className="flex items-center gap-1.5 px-2 py-0.5 bg-amber-100 text-amber-700 rounded-full text-[10px] font-bold border border-amber-200 uppercase">Human Control</div>;
406
- case "closed":
407
- return <div className="flex items-center gap-1.5 px-2 py-0.5 bg-slate-100 text-slate-600 rounded-full text-[10px] font-bold border border-slate-200 uppercase">Closed</div>;
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
- const PROVIDER_METADATA: Record<string, any> = {
32
- zoho: {
33
- name: "Zoho CRM",
34
- description: "Sync leads and contacts directly into your Zoho CRM pipeline.",
35
- icon: '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>',
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 buildDefaultCards = () =>
61
- Object.keys(PROVIDER_METADATA).map(providerKey => ({
62
- id: providerKey,
63
- provider: providerKey,
64
- name: PROVIDER_METADATA[providerKey].name,
65
- description: PROVIDER_METADATA[providerKey].description,
66
- icon: PROVIDER_METADATA[providerKey].icon,
 
 
 
 
 
 
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 = Object.keys(PROVIDER_METADATA).map(providerKey => {
77
- const backendRecord = res.data?.find(r => r.provider === providerKey);
78
  return {
79
- id: backendRecord?.id || providerKey,
80
- provider: providerKey,
81
- name: PROVIDER_METADATA[providerKey].name,
82
- description: PROVIDER_METADATA[providerKey].description,
83
- icon: PROVIDER_METADATA[providerKey].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 any);
89
  } else {
90
  setFetchError(res.error || "Failed to load integrations.");
91
- setIntegrations(buildDefaultCards() as any);
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
- // Extract provider_workspace_id from payload based on provider
105
- let pwid = "";
106
- if (configModal.provider === "whatsapp") pwid = configPayload.phone_number_id;
107
- else if (configModal.provider === "meta") pwid = configPayload.page_id;
108
- else pwid = configPayload.org_id;
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
- if (!confirm(`Are you sure you want to disconnect ${provider}?`)) return;
 
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={metadata.icon} alt={metadata.name} className="w-8 h-8 object-contain" />
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">{metadata.name}</h3>
196
  <p className="text-xs text-slate-500 leading-relaxed min-h-[40px]">
197
- {metadata.description}
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 {metadata.name}
238
  </button>
239
  )}
240
  </div>
@@ -243,7 +239,7 @@ export default function IntegrationsPage() {
243
  })}
244
  </div>
245
 
246
- {/* Custom Integration Banner relocated */}
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={PROVIDER_METADATA[configModal.provider].icon} className="w-8 h-8" />
267
  <div>
268
- <h3 className="font-bold text-slate-900">Connect {PROVIDER_METADATA[configModal.provider].name}</h3>
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
- {PROVIDER_METADATA[configModal.provider].fields.map((f: any) => (
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: "history", label: "Version History", icon: History }
 
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
- <button
456
- onClick={() => handleDeleteFile(file.id)}
457
- className="p-2 text-slate-300 hover:text-rose-500 hover:bg-rose-50 rounded-lg transition-all opacity-0 group-hover:opacity-100"
458
- >
459
- <Trash2 className="w-4 h-4" />
460
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
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> Files like TXT and CSV are indexed automatically. For PDFs, make sure to add a summary in the "Notes" field (coming soon) or ensure they are text-searchable.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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&apos;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 &quot;Add Question&quot; 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 &quot;Add Status&quot; 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&apos;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
- <option value="Growth">Growth</option>
98
- <option value="Pro">Pro</option>
99
- <option value="Enterprise">Enterprise</option>
 
 
 
 
 
 
 
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
+ }