Ashraf Al-Kassem Claude Sonnet 4.6 commited on
Commit
d4d1956
·
1 Parent(s): c626151

feat: Mission 27 — Automation Builder v2 + Template Catalog Foundation

Browse files

- Visual canvas editor (ReactFlow @xyflow/react v12) with TriggerNode, ActionNode, NodePalette, NodeConfigPanel
- FlowDraft model: per-flow mutable builder graph, separate from immutable FlowVersion
- Builder v2 endpoints: GET/PUT draft, validate, publish, simulate, rollback, versions
- builder_translator.py: validate_graph, translate, simulate — 21 unit tests passing
- AutomationTemplate + AutomationTemplateVersion with admin CRUD + workspace clone
- GET/POST /templates/ workspace catalog; POST /templates/:slug/clone
- Admin: GET/POST /admin/templates, versions, publish endpoints
- Alembic migration g7h8i9j0k1l2 + merge migration 2163b9b16da8 (M20+M27)
- Frontend: automations-api.ts, templates-api.ts; admin/templates pages
- All 57 tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (30) hide show
  1. backend/alembic/versions/2163b9b16da8_merge_m20_m27_heads.py +28 -0
  2. backend/alembic/versions/g7h8i9j0k1l2_mission_27_builder_v2.py +152 -0
  3. backend/app/api/v1/admin.py +296 -1
  4. backend/app/api/v1/automations.py +512 -53
  5. backend/app/api/v1/templates.py +229 -0
  6. backend/app/core/catalog_registry.py +27 -1
  7. backend/app/domain/builder_translator.py +316 -0
  8. backend/app/models/models.py +60 -1
  9. backend/app/workers/tasks.py +22 -6
  10. backend/main.py +2 -1
  11. backend/tests/test_automation.py +469 -26
  12. backend/tests/test_builder_translator.py +331 -0
  13. backend/tests/test_templates.py +536 -0
  14. frontend/package-lock.json +233 -2
  15. frontend/package.json +1 -0
  16. frontend/src/app/(admin)/admin/templates/[id]/page.tsx +356 -0
  17. frontend/src/app/(admin)/admin/templates/page.tsx +228 -0
  18. frontend/src/app/(dashboard)/automations/[id]/NodeConfigPanel.tsx +323 -0
  19. frontend/src/app/(dashboard)/automations/[id]/NodePalette.tsx +182 -0
  20. frontend/src/app/(dashboard)/automations/[id]/nodes/ActionNode.tsx +168 -0
  21. frontend/src/app/(dashboard)/automations/[id]/nodes/TriggerNode.tsx +68 -0
  22. frontend/src/app/(dashboard)/automations/[id]/page.tsx +812 -184
  23. frontend/src/app/(dashboard)/automations/page.tsx +34 -14
  24. frontend/src/app/(dashboard)/templates/[slug]/page.tsx +289 -0
  25. frontend/src/app/(dashboard)/templates/page.tsx +267 -0
  26. frontend/src/components/AdminSidebar.tsx +2 -0
  27. frontend/src/components/Sidebar.tsx +2 -0
  28. frontend/src/lib/admin-api.ts +90 -0
  29. frontend/src/lib/automations-api.ts +164 -0
  30. frontend/src/lib/templates-api.ts +73 -0
backend/alembic/versions/2163b9b16da8_merge_m20_m27_heads.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """merge_m20_m27_heads
2
+
3
+ Revision ID: 2163b9b16da8
4
+ Revises: f6g7h8i9j0k1, g7h8i9j0k1l2
5
+ Create Date: 2026-02-28 09:08:59.083592
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '2163b9b16da8'
16
+ down_revision: Union[str, Sequence[str], None] = ('f6g7h8i9j0k1', 'g7h8i9j0k1l2')
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ pass
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade schema."""
28
+ pass
backend/alembic/versions/g7h8i9j0k1l2_mission_27_builder_v2.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mission 27 — Automation Builder v2 + Template Catalog Foundation
2
+
3
+ Revision ID: g7h8i9j0k1l2
4
+ Revises: e5f6g7h8i9j0
5
+ Create Date: 2026-02-28
6
+
7
+ Adds:
8
+ - flow.published_version_id (FK to flowversion, use_alter=True circular ref)
9
+ - flowdraft table (one per flow, editable builder graph)
10
+ - automationtemplate table (global admin-managed templates)
11
+ - automationtemplateversion table (immutable template snapshots)
12
+ """
13
+ from alembic import op
14
+ import sqlalchemy as sa
15
+
16
+ # revision identifiers, used by Alembic.
17
+ revision = "g7h8i9j0k1l2"
18
+ down_revision = "e5f6g7h8i9j0"
19
+ branch_labels = None
20
+ depends_on = None
21
+
22
+
23
+ def upgrade() -> None:
24
+ bind = op.get_bind()
25
+ is_sqlite = bind.dialect.name == "sqlite"
26
+
27
+ # 1. Add published_version_id to flow table
28
+ op.add_column(
29
+ "flow",
30
+ sa.Column("published_version_id", sa.Uuid(), nullable=True)
31
+ )
32
+ op.create_index("ix_flow_published_version_id", "flow", ["published_version_id"])
33
+ # SQLite does not support ALTER TABLE ADD CONSTRAINT — skip FK for SQLite
34
+ if not is_sqlite:
35
+ op.create_foreign_key(
36
+ "fk_flow_published_version_id",
37
+ "flow", "flowversion",
38
+ ["published_version_id"], ["id"],
39
+ use_alter=True
40
+ )
41
+
42
+ # 2. Backfill published_version_id for existing flows that have published versions
43
+ op.execute("""
44
+ UPDATE flow
45
+ SET published_version_id = (
46
+ SELECT fv.id FROM flowversion fv
47
+ WHERE fv.flow_id = flow.id
48
+ AND fv.is_published = TRUE
49
+ ORDER BY fv.version_number DESC
50
+ LIMIT 1
51
+ )
52
+ WHERE EXISTS (
53
+ SELECT 1 FROM flowversion fv2
54
+ WHERE fv2.flow_id = flow.id
55
+ AND fv2.is_published = TRUE
56
+ )
57
+ """)
58
+
59
+ # 3. Create flowdraft table
60
+ op.create_table(
61
+ "flowdraft",
62
+ sa.Column("id", sa.Uuid(), nullable=False),
63
+ sa.Column("created_at", sa.DateTime(), nullable=False),
64
+ sa.Column("updated_at", sa.DateTime(), nullable=False),
65
+ sa.Column("workspace_id", sa.Uuid(), nullable=False),
66
+ sa.Column("flow_id", sa.Uuid(), nullable=False),
67
+ sa.Column("builder_graph_json", sa.JSON(), nullable=False),
68
+ sa.Column("updated_by_user_id", sa.Uuid(), nullable=True),
69
+ sa.Column("last_validation_errors", sa.JSON(), nullable=True),
70
+ sa.ForeignKeyConstraint(["flow_id"], ["flow.id"]),
71
+ sa.PrimaryKeyConstraint("id"),
72
+ sa.UniqueConstraint("flow_id", name="uq_flowdraft_flow_id"),
73
+ )
74
+ op.create_index("ix_flowdraft_workspace_id", "flowdraft", ["workspace_id"])
75
+ op.create_index("idx_flowdraft_ws_updated", "flowdraft", ["workspace_id", "updated_at"])
76
+
77
+ # 4. Create automationtemplate table
78
+ op.create_table(
79
+ "automationtemplate",
80
+ sa.Column("id", sa.Uuid(), nullable=False),
81
+ sa.Column("created_at", sa.DateTime(), nullable=False),
82
+ sa.Column("updated_at", sa.DateTime(), nullable=False),
83
+ sa.Column("slug", sa.String(), nullable=False),
84
+ sa.Column("name", sa.String(), nullable=False),
85
+ sa.Column("description", sa.String(), nullable=True),
86
+ sa.Column("category", sa.String(), nullable=False),
87
+ sa.Column("industry_tags", sa.JSON(), nullable=False),
88
+ sa.Column("platforms", sa.JSON(), nullable=False),
89
+ sa.Column("required_integrations", sa.JSON(), nullable=False),
90
+ sa.Column("is_featured", sa.Boolean(), nullable=False),
91
+ sa.Column("is_active", sa.Boolean(), nullable=False),
92
+ sa.Column("created_by_admin_id", sa.Uuid(), nullable=True),
93
+ sa.PrimaryKeyConstraint("id"),
94
+ sa.UniqueConstraint("slug", name="uq_automationtemplate_slug"),
95
+ )
96
+ op.create_index("ix_automationtemplate_slug", "automationtemplate", ["slug"], unique=True)
97
+ op.create_index("ix_automationtemplate_name", "automationtemplate", ["name"])
98
+ op.create_index("ix_automationtemplate_category", "automationtemplate", ["category"])
99
+ op.create_index("ix_automationtemplate_is_featured", "automationtemplate", ["is_featured"])
100
+ op.create_index("ix_automationtemplate_is_active", "automationtemplate", ["is_active"])
101
+ op.create_index("idx_template_active_featured", "automationtemplate", ["is_active", "is_featured"])
102
+
103
+ # 5. Create automationtemplateversion table
104
+ op.create_table(
105
+ "automationtemplateversion",
106
+ sa.Column("id", sa.Uuid(), nullable=False),
107
+ sa.Column("created_at", sa.DateTime(), nullable=False),
108
+ sa.Column("updated_at", sa.DateTime(), nullable=False),
109
+ sa.Column("template_id", sa.Uuid(), nullable=False),
110
+ sa.Column("version_number", sa.Integer(), nullable=False),
111
+ sa.Column("builder_graph_json", sa.JSON(), nullable=False),
112
+ sa.Column("translated_definition_json", sa.JSON(), nullable=True),
113
+ sa.Column("changelog", sa.String(), nullable=True),
114
+ sa.Column("created_by_admin_id", sa.Uuid(), nullable=True),
115
+ sa.Column("is_published", sa.Boolean(), nullable=False),
116
+ sa.Column("published_at", sa.DateTime(), nullable=True),
117
+ sa.ForeignKeyConstraint(["template_id"], ["automationtemplate.id"]),
118
+ sa.PrimaryKeyConstraint("id"),
119
+ )
120
+ op.create_index("ix_automationtemplateversion_template_id", "automationtemplateversion", ["template_id"])
121
+ op.create_index("ix_automationtemplateversion_is_published", "automationtemplateversion", ["is_published"])
122
+ op.create_index("idx_atv_template_ver", "automationtemplateversion", ["template_id", "version_number"])
123
+ op.create_index("idx_atv_template_published", "automationtemplateversion", ["template_id", "is_published"])
124
+
125
+
126
+ def downgrade() -> None:
127
+ bind = op.get_bind()
128
+ is_sqlite = bind.dialect.name == "sqlite"
129
+
130
+ # Reverse order
131
+ op.drop_index("idx_atv_template_published", table_name="automationtemplateversion")
132
+ op.drop_index("idx_atv_template_ver", table_name="automationtemplateversion")
133
+ op.drop_index("ix_automationtemplateversion_is_published", table_name="automationtemplateversion")
134
+ op.drop_index("ix_automationtemplateversion_template_id", table_name="automationtemplateversion")
135
+ op.drop_table("automationtemplateversion")
136
+
137
+ op.drop_index("idx_template_active_featured", table_name="automationtemplate")
138
+ op.drop_index("ix_automationtemplate_is_active", table_name="automationtemplate")
139
+ op.drop_index("ix_automationtemplate_is_featured", table_name="automationtemplate")
140
+ op.drop_index("ix_automationtemplate_category", table_name="automationtemplate")
141
+ op.drop_index("ix_automationtemplate_name", table_name="automationtemplate")
142
+ op.drop_index("ix_automationtemplate_slug", table_name="automationtemplate")
143
+ op.drop_table("automationtemplate")
144
+
145
+ op.drop_index("idx_flowdraft_ws_updated", table_name="flowdraft")
146
+ op.drop_index("ix_flowdraft_workspace_id", table_name="flowdraft")
147
+ op.drop_table("flowdraft")
148
+
149
+ if not is_sqlite:
150
+ op.drop_constraint("fk_flow_published_version_id", "flow", type_="foreignkey")
151
+ op.drop_index("ix_flow_published_version_id", table_name="flow")
152
+ op.drop_column("flow", "published_version_id")
backend/app/api/v1/admin.py CHANGED
@@ -5,7 +5,7 @@ from sqlmodel import select, func
5
  from pydantic import BaseModel
6
  import platform
7
 
8
- from datetime import timedelta
9
  from uuid import UUID
10
 
11
  from app.core.db import get_db
@@ -27,7 +27,9 @@ from app.models.models import (
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
33
  from app.core.audit import log_admin_action
@@ -1795,3 +1797,296 @@ async def reset_workspace_qualification(
1795
  "qualification_questions": config.qualification_questions,
1796
  "qualification_statuses": config.qualification_statuses,
1797
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  from pydantic import BaseModel
6
  import platform
7
 
8
+ from datetime import datetime, timedelta
9
  from uuid import UUID
10
 
11
  from app.core.db import get_db
 
27
  AgencyAccount, AgencyMember, AgencyStatus, WorkspaceOwnership,
28
  RuntimeEventLog,
29
  QualificationConfig,
30
+ AutomationTemplate, AutomationTemplateVersion,
31
  )
32
+ from app.domain.builder_translator import validate_graph, translate
33
  from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
34
  from app.core.modules import module_cache, ALL_MODULES, MODULE_ADMIN_PORTAL
35
  from app.core.audit import log_admin_action
 
1797
  "qualification_questions": config.qualification_questions,
1798
  "qualification_statuses": config.qualification_statuses,
1799
  })
1800
+
1801
+
1802
+ # ─── Template Catalog Admin Endpoints (Mission 27) ────────────────────────────
1803
+
1804
+ class CreateTemplatePayload(BaseModel):
1805
+ slug: str
1806
+ name: str
1807
+ description: Optional[str] = None
1808
+ category: str = "general"
1809
+ industry_tags: List[str] = []
1810
+ platforms: List[str] = []
1811
+ required_integrations: List[str] = []
1812
+ is_featured: bool = False
1813
+
1814
+
1815
+ class PatchTemplatePayload(BaseModel):
1816
+ name: Optional[str] = None
1817
+ description: Optional[str] = None
1818
+ category: Optional[str] = None
1819
+ is_featured: Optional[bool] = None
1820
+ is_active: Optional[bool] = None
1821
+ industry_tags: Optional[List[str]] = None
1822
+ platforms: Optional[List[str]] = None
1823
+ required_integrations: Optional[List[str]] = None
1824
+
1825
+
1826
+ class CreateTemplateVersionPayload(BaseModel):
1827
+ builder_graph_json: Dict[str, Any]
1828
+ changelog: Optional[str] = None
1829
+
1830
+
1831
+ @router.get("/templates", response_model=ResponseEnvelope[dict])
1832
+ async def admin_list_templates(
1833
+ db: AsyncSession = Depends(get_db),
1834
+ admin_user: User = Depends(require_superadmin),
1835
+ skip: int = Query(0, ge=0),
1836
+ limit: int = Query(50, ge=1, le=100),
1837
+ ) -> Any:
1838
+ """List all automation templates (admin view, includes inactive)."""
1839
+ count_res = await db.execute(select(func.count(AutomationTemplate.id)))
1840
+ total = count_res.scalar_one() or 0
1841
+
1842
+ result = await db.execute(
1843
+ select(AutomationTemplate)
1844
+ .order_by(AutomationTemplate.created_at.desc())
1845
+ .offset(skip).limit(limit)
1846
+ )
1847
+ templates = result.scalars().all()
1848
+
1849
+ return wrap_data({
1850
+ "items": [
1851
+ {
1852
+ "id": str(t.id),
1853
+ "slug": t.slug,
1854
+ "name": t.name,
1855
+ "description": t.description,
1856
+ "category": t.category,
1857
+ "industry_tags": t.industry_tags or [],
1858
+ "platforms": t.platforms or [],
1859
+ "required_integrations": t.required_integrations or [],
1860
+ "is_featured": t.is_featured,
1861
+ "is_active": t.is_active,
1862
+ "created_at": t.created_at.isoformat(),
1863
+ }
1864
+ for t in templates
1865
+ ],
1866
+ "total": total,
1867
+ })
1868
+
1869
+
1870
+ @router.post("/templates", response_model=ResponseEnvelope[dict])
1871
+ async def admin_create_template(
1872
+ payload: CreateTemplatePayload,
1873
+ request: Request,
1874
+ db: AsyncSession = Depends(get_db),
1875
+ admin_user: User = Depends(require_superadmin),
1876
+ ) -> Any:
1877
+ """Create a new automation template."""
1878
+ # Check slug uniqueness
1879
+ existing = await db.execute(
1880
+ select(AutomationTemplate).where(AutomationTemplate.slug == payload.slug)
1881
+ )
1882
+ if existing.scalars().first():
1883
+ return wrap_error(f"Template with slug '{payload.slug}' already exists")
1884
+
1885
+ now = datetime.utcnow()
1886
+ template = AutomationTemplate(
1887
+ slug=payload.slug,
1888
+ name=payload.name,
1889
+ description=payload.description,
1890
+ category=payload.category,
1891
+ industry_tags=payload.industry_tags,
1892
+ platforms=payload.platforms,
1893
+ required_integrations=payload.required_integrations,
1894
+ is_featured=payload.is_featured,
1895
+ is_active=True,
1896
+ created_by_admin_id=admin_user.id,
1897
+ created_at=now,
1898
+ updated_at=now,
1899
+ )
1900
+ db.add(template)
1901
+ await audit_event(
1902
+ db, action="template_create", entity_type="automation_template",
1903
+ entity_id=payload.slug, actor_user_id=admin_user.id,
1904
+ actor_type="admin", outcome="success", request=request,
1905
+ metadata={"name": payload.name},
1906
+ )
1907
+ await db.commit()
1908
+ await db.refresh(template)
1909
+
1910
+ return wrap_data({"id": str(template.id), "slug": template.slug, "name": template.name})
1911
+
1912
+
1913
+ @router.patch("/templates/{template_id}", response_model=ResponseEnvelope[dict])
1914
+ async def admin_patch_template(
1915
+ template_id: UUID,
1916
+ payload: PatchTemplatePayload,
1917
+ request: Request,
1918
+ db: AsyncSession = Depends(get_db),
1919
+ admin_user: User = Depends(require_superadmin),
1920
+ ) -> Any:
1921
+ """Update template metadata."""
1922
+ template = await db.get(AutomationTemplate, template_id)
1923
+ if not template:
1924
+ return wrap_error("Template not found")
1925
+
1926
+ if payload.name is not None:
1927
+ template.name = payload.name
1928
+ if payload.description is not None:
1929
+ template.description = payload.description
1930
+ if payload.category is not None:
1931
+ template.category = payload.category
1932
+ if payload.is_featured is not None:
1933
+ template.is_featured = payload.is_featured
1934
+ if payload.is_active is not None:
1935
+ template.is_active = payload.is_active
1936
+ if payload.industry_tags is not None:
1937
+ template.industry_tags = payload.industry_tags
1938
+ if payload.platforms is not None:
1939
+ template.platforms = payload.platforms
1940
+ if payload.required_integrations is not None:
1941
+ template.required_integrations = payload.required_integrations
1942
+
1943
+ template.updated_at = datetime.utcnow()
1944
+ db.add(template)
1945
+
1946
+ await audit_event(
1947
+ db, action="template_update", entity_type="automation_template",
1948
+ entity_id=str(template_id), actor_user_id=admin_user.id,
1949
+ actor_type="admin", outcome="success", request=request,
1950
+ )
1951
+ await db.commit()
1952
+ return wrap_data({"id": str(template.id), "updated": True})
1953
+
1954
+
1955
+ @router.post("/templates/{template_id}/versions", response_model=ResponseEnvelope[dict])
1956
+ async def admin_create_template_version(
1957
+ template_id: UUID,
1958
+ payload: CreateTemplateVersionPayload,
1959
+ request: Request,
1960
+ db: AsyncSession = Depends(get_db),
1961
+ admin_user: User = Depends(require_superadmin),
1962
+ ) -> Any:
1963
+ """Create a new draft version for a template (validates the graph)."""
1964
+ template = await db.get(AutomationTemplate, template_id)
1965
+ if not template:
1966
+ return wrap_error("Template not found")
1967
+
1968
+ # Validate the graph
1969
+ errors = validate_graph(payload.builder_graph_json)
1970
+ if errors:
1971
+ return wrap_data({"valid": False, "errors": errors})
1972
+
1973
+ # Translate to runtime contract for audit/preview
1974
+ try:
1975
+ translated = translate(payload.builder_graph_json)
1976
+ except Exception as e:
1977
+ translated = None
1978
+
1979
+ # Get next version number
1980
+ version_result = await db.execute(
1981
+ select(func.max(AutomationTemplateVersion.version_number))
1982
+ .where(AutomationTemplateVersion.template_id == template_id)
1983
+ )
1984
+ max_ver = version_result.scalar() or 0
1985
+ new_ver_num = max_ver + 1
1986
+
1987
+ now = datetime.utcnow()
1988
+ version = AutomationTemplateVersion(
1989
+ template_id=template_id,
1990
+ version_number=new_ver_num,
1991
+ builder_graph_json=payload.builder_graph_json,
1992
+ translated_definition_json=translated,
1993
+ changelog=payload.changelog,
1994
+ created_by_admin_id=admin_user.id,
1995
+ is_published=False,
1996
+ created_at=now,
1997
+ updated_at=now,
1998
+ )
1999
+ db.add(version)
2000
+
2001
+ await audit_event(
2002
+ db, action="template_version_create", entity_type="automation_template_version",
2003
+ entity_id=str(template_id), actor_user_id=admin_user.id,
2004
+ actor_type="admin", outcome="success", request=request,
2005
+ metadata={"version_number": new_ver_num},
2006
+ )
2007
+ await db.commit()
2008
+ await db.refresh(version)
2009
+
2010
+ return wrap_data({
2011
+ "valid": True,
2012
+ "id": str(version.id),
2013
+ "version_number": version.version_number,
2014
+ })
2015
+
2016
+
2017
+ @router.post("/templates/{template_id}/publish", response_model=ResponseEnvelope[dict])
2018
+ async def admin_publish_template(
2019
+ template_id: UUID,
2020
+ request: Request,
2021
+ db: AsyncSession = Depends(get_db),
2022
+ admin_user: User = Depends(require_superadmin),
2023
+ ) -> Any:
2024
+ """Publish the latest draft version of a template."""
2025
+ template = await db.get(AutomationTemplate, template_id)
2026
+ if not template:
2027
+ return wrap_error("Template not found")
2028
+
2029
+ # Get latest unpublished version
2030
+ result = await db.execute(
2031
+ select(AutomationTemplateVersion)
2032
+ .where(
2033
+ AutomationTemplateVersion.template_id == template_id,
2034
+ AutomationTemplateVersion.is_published == False,
2035
+ )
2036
+ .order_by(AutomationTemplateVersion.version_number.desc())
2037
+ .limit(1)
2038
+ )
2039
+ version = result.scalars().first()
2040
+ if not version:
2041
+ return wrap_error("No unpublished version found to publish")
2042
+
2043
+ now = datetime.utcnow()
2044
+ version.is_published = True
2045
+ version.published_at = now
2046
+ version.updated_at = now
2047
+ db.add(version)
2048
+
2049
+ await audit_event(
2050
+ db, action="template_publish", entity_type="automation_template_version",
2051
+ entity_id=str(template_id), actor_user_id=admin_user.id,
2052
+ actor_type="admin", outcome="success", request=request,
2053
+ metadata={"version_number": version.version_number},
2054
+ )
2055
+ await db.commit()
2056
+
2057
+ return wrap_data({
2058
+ "published": True,
2059
+ "version_number": version.version_number,
2060
+ "published_at": now.isoformat(),
2061
+ })
2062
+
2063
+
2064
+ @router.get("/templates/{template_id}/versions", response_model=ResponseEnvelope[list])
2065
+ async def admin_list_template_versions(
2066
+ template_id: UUID,
2067
+ db: AsyncSession = Depends(get_db),
2068
+ admin_user: User = Depends(require_superadmin),
2069
+ ) -> Any:
2070
+ """List all versions for a template."""
2071
+ template = await db.get(AutomationTemplate, template_id)
2072
+ if not template:
2073
+ return wrap_error("Template not found")
2074
+
2075
+ result = await db.execute(
2076
+ select(AutomationTemplateVersion)
2077
+ .where(AutomationTemplateVersion.template_id == template_id)
2078
+ .order_by(AutomationTemplateVersion.version_number.desc())
2079
+ )
2080
+ versions = result.scalars().all()
2081
+
2082
+ return wrap_data([
2083
+ {
2084
+ "id": str(v.id),
2085
+ "version_number": v.version_number,
2086
+ "changelog": v.changelog,
2087
+ "is_published": v.is_published,
2088
+ "published_at": v.published_at.isoformat() if v.published_at else None,
2089
+ "created_at": v.created_at.isoformat(),
2090
+ }
2091
+ for v in versions
2092
+ ])
backend/app/api/v1/automations.py CHANGED
@@ -1,24 +1,32 @@
1
  from typing import List, Optional, Dict, Any
2
  from uuid import UUID
3
  import uuid
 
4
  from fastapi import APIRouter, Depends, HTTPException, Request
5
  from sqlalchemy.ext.asyncio import AsyncSession
6
- from sqlmodel import select
7
  from pydantic import BaseModel
8
 
9
  from app.core.db import get_db
10
  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.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
 
18
  router = APIRouter()
19
 
 
 
 
 
20
  class BuilderStep(BaseModel):
21
- type: str # AI_REPLY, SEND_MESSAGE, HUMAN_HANDOVER, TAG_CONTACT
22
  config: Dict[str, Any]
23
 
24
  class BuilderTrigger(BaseModel):
@@ -33,14 +41,83 @@ class BuilderPayload(BaseModel):
33
  steps: List[BuilderStep]
34
  publish: bool = False
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  @router.get("", dependencies=[Depends(require_entitlement("automations"))])
37
  async def list_flows(
38
  db: AsyncSession = Depends(get_db),
39
- current_user: User = Depends(deps.get_current_user)
 
40
  ):
41
  """List all flows for the workspace."""
42
- result = await db.execute(select(Flow))
43
- return wrap_data(result.scalars().all())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  @router.post("/from-builder", dependencies=[Depends(require_entitlement("automations", increment=True))])
46
  async def create_from_builder(
@@ -50,63 +127,51 @@ async def create_from_builder(
50
  workspace: Workspace = Depends(deps.get_active_workspace),
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 = []
64
-
65
- # Trigger Node
66
  trigger_id = str(uuid.uuid4())
67
  nodes.append({
68
  "id": trigger_id,
69
  "type": "TRIGGER",
70
  "config": payload.trigger.model_dump()
71
  })
72
-
73
  prev_node_id = trigger_id
74
-
75
- # Action Nodes
76
  for step in payload.steps:
77
  node_id = str(uuid.uuid4())
78
  config = step.config.copy()
79
-
80
- # Default AI config hardening (Mission 5.3)
81
  if step.type == "AI_REPLY":
82
  if "goal" not in config or not config["goal"]:
83
  config["goal"] = "General assisting"
84
  if "tasks" not in config or not config["tasks"]:
85
  config["tasks"] = ["Help the user with their inquiry"]
86
-
87
  config["use_workspace_prompt"] = True
88
-
89
- nodes.append({
90
- "id": node_id,
91
- "type": step.type,
92
- "config": config
93
- })
94
-
95
- # Sequentially link
96
  edges.append({
97
  "id": str(uuid.uuid4()),
98
  "source_node_id": prev_node_id,
99
  "target_node_id": node_id
100
  })
101
  prev_node_id = node_id
102
-
103
  definition = {
 
104
  "nodes": nodes,
105
  "edges": edges,
106
  "ignore_outbound_webhooks": True
107
  }
108
-
109
- # 2. Save Flow
110
  flow = Flow(
111
  name=payload.name,
112
  description=payload.description,
@@ -115,8 +180,7 @@ async def create_from_builder(
115
  )
116
  db.add(flow)
117
  await db.flush()
118
-
119
- # 3. Create Version
120
  version = FlowVersion(
121
  flow_id=flow.id,
122
  version_number=1,
@@ -124,6 +188,12 @@ async def create_from_builder(
124
  is_published=payload.publish
125
  )
126
  db.add(version)
 
 
 
 
 
 
127
  await audit_event(
128
  db, action="automation_create", entity_type="flow",
129
  entity_id=str(flow.id), actor_user_id=current_user.id,
@@ -134,17 +204,22 @@ async def create_from_builder(
134
 
135
  return wrap_data({"flow_id": str(flow.id), "version": 1})
136
 
 
 
 
 
137
  @router.get("/{flow_id}")
138
  async def get_flow(
139
  flow_id: UUID,
140
  db: AsyncSession = Depends(get_db),
141
- current_user: User = Depends(deps.get_current_user)
 
142
  ):
143
- """Get flow along with its summary definition."""
144
  flow = await db.get(Flow, flow_id)
145
- if not flow:
146
  return wrap_error("Flow not found")
147
-
148
  result = await db.execute(
149
  select(FlowVersion)
150
  .where(FlowVersion.flow_id == flow_id)
@@ -152,48 +227,432 @@ async def get_flow(
152
  .limit(1)
153
  )
154
  version = result.scalars().first()
155
-
156
  return wrap_data({
157
- "id": flow.id,
158
  "name": flow.name,
159
  "description": flow.description,
160
  "status": flow.status,
161
- "definition": version.definition_json if version else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  })
163
 
 
 
 
 
164
  @router.post("/{flow_id}/publish")
165
  async def publish_flow(
166
  flow_id: UUID,
167
  request: Request,
168
  db: AsyncSession = Depends(get_db),
 
169
  current_user: User = Depends(deps.get_current_user),
170
  ):
171
- """Publish a flow version."""
 
 
 
172
  flow = await db.get(Flow, flow_id)
173
- if not flow:
174
  return wrap_error("Flow not found")
175
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  result = await db.execute(
177
  select(FlowVersion)
178
  .where(FlowVersion.flow_id == flow_id)
179
  .order_by(FlowVersion.version_number.desc())
180
- .limit(1)
181
  )
182
- last_version = result.scalars().first()
183
-
184
- if not last_version:
185
- return wrap_error("No version found to publish")
186
-
187
- last_version.is_published = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  flow.status = FlowStatus.PUBLISHED
189
-
190
- db.add(last_version)
191
  db.add(flow)
 
192
  await audit_event(
193
- db, action="automation_publish", entity_type="flow",
194
  entity_id=str(flow.id), actor_user_id=current_user.id,
195
- outcome="success", workspace_id=flow.workspace_id, request=request,
 
 
 
 
196
  )
197
  await db.commit()
198
 
199
- return wrap_data({"status": "published"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from typing import List, Optional, Dict, Any
2
  from uuid import UUID
3
  import uuid
4
+ from datetime import datetime
5
  from fastapi import APIRouter, Depends, HTTPException, Request
6
  from sqlalchemy.ext.asyncio import AsyncSession
7
+ from sqlmodel import select, func
8
  from pydantic import BaseModel
9
 
10
  from app.core.db import get_db
11
  from app.api import deps
12
+ from app.models.models import (
13
+ Flow, FlowVersion, FlowDraft, FlowStatus, User, Workspace,
14
+ RuntimeEventLog,
15
+ )
16
  from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
17
  from app.core.catalog_registry import VALID_NODE_TYPES, VALID_TRIGGER_TYPES
18
  from app.services.entitlements import require_entitlement
19
  from app.services.audit_service import audit_event
20
+ from app.domain.builder_translator import validate_graph, translate, simulate
21
 
22
  router = APIRouter()
23
 
24
+ # ---------------------------------------------------------------------------
25
+ # Request schemas
26
+ # ---------------------------------------------------------------------------
27
+
28
  class BuilderStep(BaseModel):
29
+ type: str # AI_REPLY, SEND_MESSAGE, HUMAN_HANDOVER, TAG_CONTACT
30
  config: Dict[str, Any]
31
 
32
  class BuilderTrigger(BaseModel):
 
41
  steps: List[BuilderStep]
42
  publish: bool = False
43
 
44
+ class CreateFlowPayload(BaseModel):
45
+ name: str
46
+ description: Optional[str] = ""
47
+
48
+ class UpdateFlowPayload(BaseModel):
49
+ name: Optional[str] = None
50
+ description: Optional[str] = None
51
+
52
+ class SaveDraftPayload(BaseModel):
53
+ builder_graph_json: Dict[str, Any]
54
+
55
+ class SimulatePayload(BaseModel):
56
+ mock_payload: Optional[Dict[str, Any]] = None
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # GET /automations — list
60
+ # ---------------------------------------------------------------------------
61
+
62
  @router.get("", dependencies=[Depends(require_entitlement("automations"))])
63
  async def list_flows(
64
  db: AsyncSession = Depends(get_db),
65
+ workspace: Workspace = Depends(deps.get_active_workspace),
66
+ current_user: User = Depends(deps.get_current_user),
67
  ):
68
  """List all flows for the workspace."""
69
+ result = await db.execute(
70
+ select(Flow).where(Flow.workspace_id == workspace.id)
71
+ .order_by(Flow.updated_at.desc())
72
+ )
73
+ flows = result.scalars().all()
74
+ return wrap_data([
75
+ {
76
+ "id": str(f.id),
77
+ "name": f.name,
78
+ "description": f.description,
79
+ "status": f.status,
80
+ "published_version_id": str(f.published_version_id) if f.published_version_id else None,
81
+ "created_at": f.created_at.isoformat(),
82
+ "updated_at": f.updated_at.isoformat(),
83
+ }
84
+ for f in flows
85
+ ])
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # POST /automations — create blank flow (canvas-first approach)
89
+ # ---------------------------------------------------------------------------
90
+
91
+ @router.post("", dependencies=[Depends(require_entitlement("automations", increment=True))])
92
+ async def create_flow(
93
+ payload: CreateFlowPayload,
94
+ request: Request,
95
+ db: AsyncSession = Depends(get_db),
96
+ workspace: Workspace = Depends(deps.get_active_workspace),
97
+ current_user: User = Depends(deps.get_current_user),
98
+ ):
99
+ """Create a blank flow. Returns flow_id for redirect to canvas editor."""
100
+ flow = Flow(
101
+ name=payload.name,
102
+ description=payload.description,
103
+ workspace_id=workspace.id,
104
+ status=FlowStatus.DRAFT,
105
+ )
106
+ db.add(flow)
107
+ await db.flush()
108
+
109
+ await audit_event(
110
+ db, action="automation_create", entity_type="flow",
111
+ entity_id=str(flow.id), actor_user_id=current_user.id,
112
+ outcome="success", workspace_id=workspace.id, request=request,
113
+ metadata={"flow_name": payload.name, "method": "blank"},
114
+ )
115
+ await db.commit()
116
+ return wrap_data({"flow_id": str(flow.id)})
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # POST /automations/from-builder — PRESERVED (wizard-based, backward compat)
120
+ # ---------------------------------------------------------------------------
121
 
122
  @router.post("/from-builder", dependencies=[Depends(require_entitlement("automations", increment=True))])
123
  async def create_from_builder(
 
127
  workspace: Workspace = Depends(deps.get_active_workspace),
128
  current_user: User = Depends(deps.get_current_user),
129
  ):
130
+ """Translate wizard payload to runtime JSON and save flow. Preserved for backward compatibility."""
 
131
  for step in payload.steps:
132
  if step.type not in VALID_NODE_TYPES:
133
  return wrap_error(f"Invalid step type: {step.type}")
134
  if payload.trigger.type not in VALID_TRIGGER_TYPES:
135
  return wrap_error(f"Invalid trigger type: {payload.trigger.type}")
136
 
 
137
  nodes = []
138
  edges = []
139
+
 
140
  trigger_id = str(uuid.uuid4())
141
  nodes.append({
142
  "id": trigger_id,
143
  "type": "TRIGGER",
144
  "config": payload.trigger.model_dump()
145
  })
146
+
147
  prev_node_id = trigger_id
148
+
 
149
  for step in payload.steps:
150
  node_id = str(uuid.uuid4())
151
  config = step.config.copy()
152
+
 
153
  if step.type == "AI_REPLY":
154
  if "goal" not in config or not config["goal"]:
155
  config["goal"] = "General assisting"
156
  if "tasks" not in config or not config["tasks"]:
157
  config["tasks"] = ["Help the user with their inquiry"]
 
158
  config["use_workspace_prompt"] = True
159
+
160
+ nodes.append({"id": node_id, "type": step.type, "config": config})
 
 
 
 
 
 
161
  edges.append({
162
  "id": str(uuid.uuid4()),
163
  "source_node_id": prev_node_id,
164
  "target_node_id": node_id
165
  })
166
  prev_node_id = node_id
167
+
168
  definition = {
169
+ "start_node_id": trigger_id,
170
  "nodes": nodes,
171
  "edges": edges,
172
  "ignore_outbound_webhooks": True
173
  }
174
+
 
175
  flow = Flow(
176
  name=payload.name,
177
  description=payload.description,
 
180
  )
181
  db.add(flow)
182
  await db.flush()
183
+
 
184
  version = FlowVersion(
185
  flow_id=flow.id,
186
  version_number=1,
 
188
  is_published=payload.publish
189
  )
190
  db.add(version)
191
+ await db.flush()
192
+
193
+ if payload.publish:
194
+ flow.published_version_id = version.id
195
+ db.add(flow)
196
+
197
  await audit_event(
198
  db, action="automation_create", entity_type="flow",
199
  entity_id=str(flow.id), actor_user_id=current_user.id,
 
204
 
205
  return wrap_data({"flow_id": str(flow.id), "version": 1})
206
 
207
+ # ---------------------------------------------------------------------------
208
+ # GET /automations/{flow_id}
209
+ # ---------------------------------------------------------------------------
210
+
211
  @router.get("/{flow_id}")
212
  async def get_flow(
213
  flow_id: UUID,
214
  db: AsyncSession = Depends(get_db),
215
+ workspace: Workspace = Depends(deps.get_active_workspace),
216
+ current_user: User = Depends(deps.get_current_user),
217
  ):
218
+ """Get flow details."""
219
  flow = await db.get(Flow, flow_id)
220
+ if not flow or flow.workspace_id != workspace.id:
221
  return wrap_error("Flow not found")
222
+
223
  result = await db.execute(
224
  select(FlowVersion)
225
  .where(FlowVersion.flow_id == flow_id)
 
227
  .limit(1)
228
  )
229
  version = result.scalars().first()
230
+
231
  return wrap_data({
232
+ "id": str(flow.id),
233
  "name": flow.name,
234
  "description": flow.description,
235
  "status": flow.status,
236
+ "published_version_id": str(flow.published_version_id) if flow.published_version_id else None,
237
+ "created_at": flow.created_at.isoformat(),
238
+ "updated_at": flow.updated_at.isoformat(),
239
+ "definition": version.definition_json if version else None,
240
+ "version_number": version.version_number if version else None,
241
+ })
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # PATCH /automations/{flow_id}
245
+ # ---------------------------------------------------------------------------
246
+
247
+ @router.patch("/{flow_id}")
248
+ async def update_flow(
249
+ flow_id: UUID,
250
+ payload: UpdateFlowPayload,
251
+ db: AsyncSession = Depends(get_db),
252
+ workspace: Workspace = Depends(deps.get_active_workspace),
253
+ current_user: User = Depends(deps.get_current_user),
254
+ ):
255
+ """Update flow metadata (name, description)."""
256
+ flow = await db.get(Flow, flow_id)
257
+ if not flow or flow.workspace_id != workspace.id:
258
+ return wrap_error("Flow not found")
259
+
260
+ if payload.name is not None:
261
+ flow.name = payload.name
262
+ if payload.description is not None:
263
+ flow.description = payload.description
264
+ flow.updated_at = datetime.utcnow()
265
+ db.add(flow)
266
+ await db.commit()
267
+ return wrap_data({"id": str(flow.id), "name": flow.name})
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # DELETE /automations/{flow_id}
271
+ # ---------------------------------------------------------------------------
272
+
273
+ @router.delete("/{flow_id}")
274
+ async def delete_flow(
275
+ flow_id: UUID,
276
+ request: Request,
277
+ db: AsyncSession = Depends(get_db),
278
+ workspace: Workspace = Depends(deps.get_active_workspace),
279
+ current_user: User = Depends(deps.get_current_user),
280
+ ):
281
+ """Delete a flow and its draft."""
282
+ flow = await db.get(Flow, flow_id)
283
+ if not flow or flow.workspace_id != workspace.id:
284
+ return wrap_error("Flow not found")
285
+
286
+ draft_result = await db.execute(
287
+ select(FlowDraft).where(FlowDraft.flow_id == flow_id)
288
+ )
289
+ draft = draft_result.scalars().first()
290
+ if draft:
291
+ await db.delete(draft)
292
+
293
+ await audit_event(
294
+ db, action="automation_delete", entity_type="flow",
295
+ entity_id=str(flow.id), actor_user_id=current_user.id,
296
+ outcome="success", workspace_id=workspace.id, request=request,
297
+ metadata={"flow_name": flow.name},
298
+ )
299
+ await db.delete(flow)
300
+ await db.commit()
301
+ return wrap_data({"deleted": True})
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # GET /automations/{flow_id}/draft
305
+ # ---------------------------------------------------------------------------
306
+
307
+ @router.get("/{flow_id}/draft")
308
+ async def get_draft(
309
+ flow_id: UUID,
310
+ db: AsyncSession = Depends(get_db),
311
+ workspace: Workspace = Depends(deps.get_active_workspace),
312
+ current_user: User = Depends(deps.get_current_user),
313
+ ):
314
+ """Get the editable draft for a flow. Returns null if no draft exists."""
315
+ flow = await db.get(Flow, flow_id)
316
+ if not flow or flow.workspace_id != workspace.id:
317
+ return wrap_error("Flow not found")
318
+
319
+ result = await db.execute(
320
+ select(FlowDraft).where(FlowDraft.flow_id == flow_id)
321
+ )
322
+ draft = result.scalars().first()
323
+
324
+ if not draft:
325
+ return wrap_data({
326
+ "builder_graph_json": None,
327
+ "last_validation_errors": None,
328
+ "updated_at": None,
329
+ })
330
+
331
+ return wrap_data({
332
+ "builder_graph_json": draft.builder_graph_json,
333
+ "last_validation_errors": draft.last_validation_errors,
334
+ "updated_at": draft.updated_at.isoformat(),
335
+ })
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # PUT /automations/{flow_id}/draft — autosave
339
+ # ---------------------------------------------------------------------------
340
+
341
+ @router.put("/{flow_id}/draft")
342
+ async def save_draft(
343
+ flow_id: UUID,
344
+ payload: SaveDraftPayload,
345
+ db: AsyncSession = Depends(get_db),
346
+ workspace: Workspace = Depends(deps.get_active_workspace),
347
+ current_user: User = Depends(deps.get_current_user),
348
+ ):
349
+ """Save (upsert) the builder graph draft. Called by autosave."""
350
+ flow = await db.get(Flow, flow_id)
351
+ if not flow or flow.workspace_id != workspace.id:
352
+ return wrap_error("Flow not found")
353
+
354
+ result = await db.execute(
355
+ select(FlowDraft).where(FlowDraft.flow_id == flow_id)
356
+ )
357
+ draft = result.scalars().first()
358
+ now = datetime.utcnow()
359
+
360
+ if draft:
361
+ draft.builder_graph_json = payload.builder_graph_json
362
+ draft.updated_by_user_id = current_user.id
363
+ draft.updated_at = now
364
+ db.add(draft)
365
+ else:
366
+ draft = FlowDraft(
367
+ workspace_id=workspace.id,
368
+ flow_id=flow_id,
369
+ builder_graph_json=payload.builder_graph_json,
370
+ updated_by_user_id=current_user.id,
371
+ updated_at=now,
372
+ )
373
+ db.add(draft)
374
+
375
+ await db.commit()
376
+ return wrap_data({"saved": True, "updated_at": now.isoformat()})
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # POST /automations/{flow_id}/draft/validate
380
+ # ---------------------------------------------------------------------------
381
+
382
+ @router.post("/{flow_id}/draft/validate")
383
+ async def validate_draft(
384
+ flow_id: UUID,
385
+ db: AsyncSession = Depends(get_db),
386
+ workspace: Workspace = Depends(deps.get_active_workspace),
387
+ current_user: User = Depends(deps.get_current_user),
388
+ ):
389
+ """Validate the current draft. Returns structured errors per node/field."""
390
+ flow = await db.get(Flow, flow_id)
391
+ if not flow or flow.workspace_id != workspace.id:
392
+ return wrap_error("Flow not found")
393
+
394
+ result = await db.execute(
395
+ select(FlowDraft).where(FlowDraft.flow_id == flow_id)
396
+ )
397
+ draft = result.scalars().first()
398
+ if not draft:
399
+ return wrap_error("No draft found. Save a draft first.")
400
+
401
+ errors = validate_graph(draft.builder_graph_json)
402
+ draft.last_validation_errors = {"errors": errors, "validated_at": datetime.utcnow().isoformat()}
403
+ draft.updated_at = datetime.utcnow()
404
+ db.add(draft)
405
+ await db.commit()
406
+
407
+ return wrap_data({
408
+ "valid": len(errors) == 0,
409
+ "errors": errors,
410
  })
411
 
412
+ # ---------------------------------------------------------------------------
413
+ # POST /automations/{flow_id}/publish — REPLACED with draft-based approach
414
+ # ---------------------------------------------------------------------------
415
+
416
  @router.post("/{flow_id}/publish")
417
  async def publish_flow(
418
  flow_id: UUID,
419
  request: Request,
420
  db: AsyncSession = Depends(get_db),
421
+ workspace: Workspace = Depends(deps.get_active_workspace),
422
  current_user: User = Depends(deps.get_current_user),
423
  ):
424
+ """
425
+ Validate draft → translate to runtime definition_json → create immutable FlowVersion.
426
+ Sets flow.published_version_id to the new version.
427
+ """
428
  flow = await db.get(Flow, flow_id)
429
+ if not flow or flow.workspace_id != workspace.id:
430
  return wrap_error("Flow not found")
431
+
432
+ result = await db.execute(
433
+ select(FlowDraft).where(FlowDraft.flow_id == flow_id)
434
+ )
435
+ draft = result.scalars().first()
436
+ if not draft:
437
+ return wrap_error("No draft found. Build your automation in the canvas editor first.")
438
+
439
+ # Validate
440
+ errors = validate_graph(draft.builder_graph_json)
441
+ if errors:
442
+ draft.last_validation_errors = {"errors": errors, "validated_at": datetime.utcnow().isoformat()}
443
+ db.add(draft)
444
+ await db.commit()
445
+ return wrap_data({
446
+ "success": False,
447
+ "published": False,
448
+ "errors": errors,
449
+ })
450
+
451
+ # Translate to runtime contract
452
+ definition_json = translate(draft.builder_graph_json)
453
+
454
+ # Get next version number
455
+ version_result = await db.execute(
456
+ select(func.max(FlowVersion.version_number))
457
+ .where(FlowVersion.flow_id == flow_id)
458
+ )
459
+ max_version = version_result.scalar() or 0
460
+ new_version_number = max_version + 1
461
+
462
+ now = datetime.utcnow()
463
+ new_version = FlowVersion(
464
+ flow_id=flow_id,
465
+ version_number=new_version_number,
466
+ definition_json=definition_json,
467
+ is_published=True,
468
+ created_at=now,
469
+ updated_at=now,
470
+ )
471
+ db.add(new_version)
472
+ await db.flush()
473
+
474
+ # Update flow
475
+ flow.published_version_id = new_version.id
476
+ flow.status = FlowStatus.PUBLISHED
477
+ flow.updated_at = now
478
+ db.add(flow)
479
+
480
+ # Clear validation errors from draft
481
+ draft.last_validation_errors = None
482
+ draft.updated_at = now
483
+ db.add(draft)
484
+
485
+ await audit_event(
486
+ db, action="automation_publish", entity_type="flow",
487
+ entity_id=str(flow.id), actor_user_id=current_user.id,
488
+ outcome="success", workspace_id=workspace.id, request=request,
489
+ metadata={"version_number": new_version_number},
490
+ )
491
+ await db.commit()
492
+
493
+ return wrap_data({
494
+ "success": True,
495
+ "published": True,
496
+ "version_id": str(new_version.id),
497
+ "version_number": new_version_number,
498
+ "published_at": now.isoformat(),
499
+ "status": "published",
500
+ })
501
+
502
+ # ---------------------------------------------------------------------------
503
+ # GET /automations/{flow_id}/versions
504
+ # ---------------------------------------------------------------------------
505
+
506
+ @router.get("/{flow_id}/versions")
507
+ async def list_versions(
508
+ flow_id: UUID,
509
+ db: AsyncSession = Depends(get_db),
510
+ workspace: Workspace = Depends(deps.get_active_workspace),
511
+ current_user: User = Depends(deps.get_current_user),
512
+ ):
513
+ """List all published versions for a flow, newest first."""
514
+ flow = await db.get(Flow, flow_id)
515
+ if not flow or flow.workspace_id != workspace.id:
516
+ return wrap_error("Flow not found")
517
+
518
  result = await db.execute(
519
  select(FlowVersion)
520
  .where(FlowVersion.flow_id == flow_id)
521
  .order_by(FlowVersion.version_number.desc())
 
522
  )
523
+ versions = result.scalars().all()
524
+
525
+ return wrap_data([
526
+ {
527
+ "id": str(v.id),
528
+ "version_number": v.version_number,
529
+ "is_published": v.is_published,
530
+ "is_active": (str(v.id) == str(flow.published_version_id)) if flow.published_version_id else False,
531
+ "created_at": v.created_at.isoformat(),
532
+ }
533
+ for v in versions
534
+ ])
535
+
536
+ # ---------------------------------------------------------------------------
537
+ # POST /automations/{flow_id}/rollback/{version_id}
538
+ # ---------------------------------------------------------------------------
539
+
540
+ @router.post("/{flow_id}/rollback/{version_id}")
541
+ async def rollback_version(
542
+ flow_id: UUID,
543
+ version_id: UUID,
544
+ request: Request,
545
+ db: AsyncSession = Depends(get_db),
546
+ workspace: Workspace = Depends(deps.get_active_workspace),
547
+ current_user: User = Depends(deps.get_current_user),
548
+ ):
549
+ """
550
+ Create a new FlowVersion identical to version_id and make it the active version.
551
+ Safe rollback — does NOT delete history.
552
+ """
553
+ flow = await db.get(Flow, flow_id)
554
+ if not flow or flow.workspace_id != workspace.id:
555
+ return wrap_error("Flow not found")
556
+
557
+ source_version = await db.get(FlowVersion, version_id)
558
+ if not source_version or source_version.flow_id != flow_id:
559
+ return wrap_error("Version not found")
560
+
561
+ # Get next version number
562
+ version_result = await db.execute(
563
+ select(func.max(FlowVersion.version_number))
564
+ .where(FlowVersion.flow_id == flow_id)
565
+ )
566
+ max_version = version_result.scalar() or 0
567
+ new_version_number = max_version + 1
568
+
569
+ now = datetime.utcnow()
570
+ new_version = FlowVersion(
571
+ flow_id=flow_id,
572
+ version_number=new_version_number,
573
+ definition_json=source_version.definition_json,
574
+ is_published=True,
575
+ created_at=now,
576
+ updated_at=now,
577
+ )
578
+ db.add(new_version)
579
+ await db.flush()
580
+
581
+ flow.published_version_id = new_version.id
582
  flow.status = FlowStatus.PUBLISHED
583
+ flow.updated_at = now
 
584
  db.add(flow)
585
+
586
  await audit_event(
587
+ db, action="automation_rollback", entity_type="flow",
588
  entity_id=str(flow.id), actor_user_id=current_user.id,
589
+ outcome="success", workspace_id=workspace.id, request=request,
590
+ metadata={
591
+ "rolled_back_to_version": source_version.version_number,
592
+ "new_version_number": new_version_number,
593
+ },
594
  )
595
  await db.commit()
596
 
597
+ return wrap_data({
598
+ "new_version_id": str(new_version.id),
599
+ "new_version_number": new_version_number,
600
+ "rolled_back_to": source_version.version_number,
601
+ })
602
+
603
+ # ---------------------------------------------------------------------------
604
+ # POST /automations/{flow_id}/simulate
605
+ # ---------------------------------------------------------------------------
606
+
607
+ @router.post("/{flow_id}/simulate")
608
+ async def simulate_flow(
609
+ flow_id: UUID,
610
+ payload: SimulatePayload,
611
+ db: AsyncSession = Depends(get_db),
612
+ workspace: Workspace = Depends(deps.get_active_workspace),
613
+ current_user: User = Depends(deps.get_current_user),
614
+ ):
615
+ """
616
+ Dry-run the automation against a mock payload.
617
+ Returns step-by-step preview. No messages sent, no provider calls.
618
+ """
619
+ flow = await db.get(Flow, flow_id)
620
+ if not flow or flow.workspace_id != workspace.id:
621
+ return wrap_error("Flow not found")
622
+
623
+ result = await db.execute(
624
+ select(FlowDraft).where(FlowDraft.flow_id == flow_id)
625
+ )
626
+ draft = result.scalars().first()
627
+ if not draft:
628
+ return wrap_error("No draft found. Build your automation first.")
629
+
630
+ # Validate first
631
+ errors = validate_graph(draft.builder_graph_json)
632
+ if errors:
633
+ return wrap_data({
634
+ "valid": False,
635
+ "errors": errors,
636
+ "steps": [],
637
+ })
638
+
639
+ # Run simulation (pure in-memory, no side effects)
640
+ preview = simulate(draft.builder_graph_json, payload.mock_payload)
641
+
642
+ # Log simulate event (non-blocking)
643
+ try:
644
+ event = RuntimeEventLog(
645
+ workspace_id=workspace.id,
646
+ event_type="simulate.run",
647
+ source="simulate",
648
+ related_ids={"flow_id": str(flow_id)},
649
+ actor_user_id=current_user.id,
650
+ payload={"steps_count": len(preview.get("steps", []))},
651
+ outcome="success",
652
+ )
653
+ db.add(event)
654
+ await db.commit()
655
+ except Exception:
656
+ pass
657
+
658
+ return wrap_data({"valid": True, **preview})
backend/app/api/v1/templates.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Template Catalog API — Mission 27
3
+
4
+ Workspace-facing endpoints for browsing and cloning automation templates.
5
+ Admin-facing endpoints are in admin.py.
6
+ """
7
+ from typing import List, Optional, Dict, Any
8
+ from uuid import UUID
9
+ from datetime import datetime
10
+ from fastapi import APIRouter, Depends, Query
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+ from sqlmodel import select
13
+ from pydantic import BaseModel
14
+
15
+ from app.core.db import get_db
16
+ from app.api import deps
17
+ from app.models.models import (
18
+ AutomationTemplate, AutomationTemplateVersion,
19
+ Flow, FlowDraft, FlowStatus,
20
+ Workspace, User, RuntimeEventLog,
21
+ )
22
+ from app.schemas.envelope import wrap_data, wrap_error
23
+ from app.services.entitlements import require_entitlement
24
+
25
+ router = APIRouter()
26
+
27
+
28
+ class CloneTemplatePayload(BaseModel):
29
+ name: Optional[str] = None # Defaults to template name if not provided
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # GET /templates — browse the catalog
34
+ # ---------------------------------------------------------------------------
35
+
36
+ @router.get("", dependencies=[Depends(require_entitlement("automations"))])
37
+ async def list_templates(
38
+ db: AsyncSession = Depends(get_db),
39
+ workspace: Workspace = Depends(deps.get_active_workspace),
40
+ current_user: User = Depends(deps.get_current_user),
41
+ category: Optional[str] = Query(None),
42
+ platform: Optional[str] = Query(None),
43
+ featured: Optional[bool] = Query(None),
44
+ skip: int = Query(0, ge=0),
45
+ limit: int = Query(50, ge=1, le=100),
46
+ ):
47
+ """List all active templates with optional filters."""
48
+ query = select(AutomationTemplate).where(AutomationTemplate.is_active == True)
49
+
50
+ if category:
51
+ query = query.where(AutomationTemplate.category == category)
52
+ if featured is not None:
53
+ query = query.where(AutomationTemplate.is_featured == featured)
54
+
55
+ query = query.order_by(
56
+ AutomationTemplate.is_featured.desc(),
57
+ AutomationTemplate.name.asc()
58
+ ).offset(skip).limit(limit)
59
+
60
+ result = await db.execute(query)
61
+ templates = result.scalars().all()
62
+
63
+ # Platform filter is done in Python since platforms is a JSON array
64
+ if platform:
65
+ templates = [t for t in templates if platform in (t.platforms or [])]
66
+
67
+ return wrap_data([
68
+ {
69
+ "id": str(t.id),
70
+ "slug": t.slug,
71
+ "name": t.name,
72
+ "description": t.description,
73
+ "category": t.category,
74
+ "industry_tags": t.industry_tags or [],
75
+ "platforms": t.platforms or [],
76
+ "required_integrations": t.required_integrations or [],
77
+ "is_featured": t.is_featured,
78
+ }
79
+ for t in templates
80
+ ])
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # GET /templates/{slug} — template detail with latest published version
85
+ # ---------------------------------------------------------------------------
86
+
87
+ @router.get("/{slug}", dependencies=[Depends(require_entitlement("automations"))])
88
+ async def get_template(
89
+ slug: str,
90
+ db: AsyncSession = Depends(get_db),
91
+ workspace: Workspace = Depends(deps.get_active_workspace),
92
+ current_user: User = Depends(deps.get_current_user),
93
+ ):
94
+ """Get template detail including latest published version."""
95
+ result = await db.execute(
96
+ select(AutomationTemplate)
97
+ .where(AutomationTemplate.slug == slug, AutomationTemplate.is_active == True)
98
+ )
99
+ template = result.scalars().first()
100
+ if not template:
101
+ return wrap_error("Template not found")
102
+
103
+ # Latest published version
104
+ version_result = await db.execute(
105
+ select(AutomationTemplateVersion)
106
+ .where(
107
+ AutomationTemplateVersion.template_id == template.id,
108
+ AutomationTemplateVersion.is_published == True,
109
+ )
110
+ .order_by(AutomationTemplateVersion.version_number.desc())
111
+ .limit(1)
112
+ )
113
+ latest_version = version_result.scalars().first()
114
+
115
+ return wrap_data({
116
+ "id": str(template.id),
117
+ "slug": template.slug,
118
+ "name": template.name,
119
+ "description": template.description,
120
+ "category": template.category,
121
+ "industry_tags": template.industry_tags or [],
122
+ "platforms": template.platforms or [],
123
+ "required_integrations": template.required_integrations or [],
124
+ "is_featured": template.is_featured,
125
+ "latest_version": {
126
+ "id": str(latest_version.id),
127
+ "version_number": latest_version.version_number,
128
+ "builder_graph_json": latest_version.builder_graph_json,
129
+ "changelog": latest_version.changelog,
130
+ "published_at": latest_version.published_at.isoformat() if latest_version.published_at else None,
131
+ } if latest_version else None,
132
+ })
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # POST /templates/{slug}/clone — clone template into workspace
137
+ # ---------------------------------------------------------------------------
138
+
139
+ @router.post(
140
+ "/{slug}/clone",
141
+ dependencies=[Depends(require_entitlement("automations", increment=True))],
142
+ )
143
+ async def clone_template(
144
+ slug: str,
145
+ payload: CloneTemplatePayload,
146
+ db: AsyncSession = Depends(get_db),
147
+ workspace: Workspace = Depends(deps.get_active_workspace),
148
+ current_user: User = Depends(deps.get_current_user),
149
+ ):
150
+ """
151
+ Clone a template into the current workspace as a new Flow + FlowDraft.
152
+ Redirects to canvas builder at /automations/{flow_id}.
153
+ """
154
+ # Load template
155
+ result = await db.execute(
156
+ select(AutomationTemplate)
157
+ .where(AutomationTemplate.slug == slug, AutomationTemplate.is_active == True)
158
+ )
159
+ template = result.scalars().first()
160
+ if not template:
161
+ return wrap_error("Template not found")
162
+
163
+ # Load latest published version
164
+ version_result = await db.execute(
165
+ select(AutomationTemplateVersion)
166
+ .where(
167
+ AutomationTemplateVersion.template_id == template.id,
168
+ AutomationTemplateVersion.is_published == True,
169
+ )
170
+ .order_by(AutomationTemplateVersion.version_number.desc())
171
+ .limit(1)
172
+ )
173
+ latest_version = version_result.scalars().first()
174
+ if not latest_version:
175
+ return wrap_error("Template has no published version yet. Check back later.")
176
+
177
+ # Create new flow in workspace
178
+ flow_name = (payload.name or "").strip() or template.name
179
+ now = datetime.utcnow()
180
+
181
+ flow = Flow(
182
+ name=flow_name,
183
+ description=template.description,
184
+ workspace_id=workspace.id,
185
+ status=FlowStatus.DRAFT,
186
+ created_at=now,
187
+ updated_at=now,
188
+ )
189
+ db.add(flow)
190
+ await db.flush()
191
+
192
+ # Create draft from template builder_graph_json
193
+ draft = FlowDraft(
194
+ workspace_id=workspace.id,
195
+ flow_id=flow.id,
196
+ builder_graph_json=latest_version.builder_graph_json,
197
+ updated_by_user_id=current_user.id,
198
+ created_at=now,
199
+ updated_at=now,
200
+ )
201
+ db.add(draft)
202
+
203
+ # Log the clone event
204
+ try:
205
+ event = RuntimeEventLog(
206
+ workspace_id=workspace.id,
207
+ event_type="template.clone",
208
+ source="templates",
209
+ related_ids={
210
+ "template_id": str(template.id),
211
+ "template_slug": slug,
212
+ "flow_id": str(flow.id),
213
+ },
214
+ actor_user_id=current_user.id,
215
+ outcome="success",
216
+ )
217
+ db.add(event)
218
+ except Exception:
219
+ pass
220
+
221
+ await db.commit()
222
+
223
+ return wrap_data({
224
+ "flow_id": str(flow.id),
225
+ "flow_name": flow_name,
226
+ "redirect_path": f"/automations/{flow.id}",
227
+ "template_slug": slug,
228
+ "required_integrations": template.required_integrations or [],
229
+ })
backend/app/core/catalog_registry.py CHANGED
@@ -54,28 +54,54 @@ AUTOMATION_NODE_TYPES: List[Dict[str, Any]] = [
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 ─────────────────────────────────────────
 
54
  "label": "AI Reply",
55
  "description": "Generate AI-powered response using workspace prompt config.",
56
  "icon_hint": "bot",
57
+ "runtime_supported": True,
58
  },
59
  {
60
  "key": "SEND_MESSAGE",
61
  "label": "Send Message",
62
  "description": "Send a message via the connected platform.",
63
  "icon_hint": "send",
64
+ "runtime_supported": True,
65
  },
66
  {
67
  "key": "HUMAN_HANDOVER",
68
  "label": "Human Handover",
69
  "description": "Transfer conversation to a human agent.",
70
  "icon_hint": "user",
71
+ "runtime_supported": True,
72
  },
73
  {
74
  "key": "TAG_CONTACT",
75
  "label": "Tag Contact",
76
  "description": "Apply a tag to the contact for segmentation.",
77
  "icon_hint": "tag",
78
+ "runtime_supported": True,
79
+ },
80
+ {
81
+ "key": "ZOHO_UPSERT_LEAD",
82
+ "label": "Zoho: Upsert Lead",
83
+ "description": "Sync contact as a lead in Zoho CRM.",
84
+ "icon_hint": "database",
85
+ "runtime_supported": True,
86
+ },
87
+ {
88
+ "key": "CONDITION",
89
+ "label": "Condition (Branch)",
90
+ "description": "Branch the flow based on a condition. (Coming soon — cannot publish yet)",
91
+ "icon_hint": "git-branch",
92
+ "runtime_supported": False,
93
+ },
94
+ {
95
+ "key": "WAIT_DELAY",
96
+ "label": "Wait / Delay",
97
+ "description": "Pause the flow for a set duration. (Coming soon — cannot publish yet)",
98
+ "icon_hint": "clock",
99
+ "runtime_supported": False,
100
  },
101
  ]
102
 
103
+ # Node types supported by the runtime engine (subset of builder palette)
104
+ VALID_NODE_TYPES = {n["key"] for n in AUTOMATION_NODE_TYPES if n.get("runtime_supported", True)}
105
 
106
 
107
  # ── Automation Trigger Types ─────────────────────────────────────────
backend/app/domain/builder_translator.py ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Builder Translator — Mission 27
3
+
4
+ Converts builder_graph_json (React Flow native format) into the runtime
5
+ definition_json contract, validates graphs before publish, and simulates
6
+ traversal without side effects.
7
+
8
+ Builder graph format (stored in FlowDraft.builder_graph_json):
9
+ {
10
+ "nodes": [
11
+ {"id": "trigger-1", "type": "triggerNode", "position": {"x": 250, "y": 50},
12
+ "data": {"nodeType": "MESSAGE_INBOUND", "platform": "whatsapp", "config": {}}},
13
+ {"id": "node-2", "type": "actionNode", "position": {"x": 250, "y": 220},
14
+ "data": {"nodeType": "AI_REPLY", "config": {"goal": "Help user", "tasks": []}}}
15
+ ],
16
+ "edges": [{"id": "e1", "source": "trigger-1", "target": "node-2"}]
17
+ }
18
+
19
+ Runtime definition_json format (stored in FlowVersion.definition_json):
20
+ {
21
+ "start_node_id": "trigger-1",
22
+ "nodes": [{"id": "...", "type": "TRIGGER", "config": {...}}, ...],
23
+ "edges": [{"id": "...", "source_node_id": "...", "target_node_id": "...", "source_handle": null}]
24
+ }
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ from typing import Any
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Trigger node types that map to runtime TRIGGER node type
34
+ TRIGGER_NODE_TYPES = {"MESSAGE_INBOUND", "LEAD_AD_SUBMIT"}
35
+
36
+ # Node types supported by the runtime engine
37
+ RUNTIME_SUPPORTED_NODE_TYPES = {
38
+ "AI_REPLY", "SEND_MESSAGE", "HUMAN_HANDOVER",
39
+ "TAG_CONTACT", "ZOHO_UPSERT_LEAD",
40
+ }
41
+
42
+ # Builder-only node types (visual palette only; blocked from publishing)
43
+ BUILDER_ONLY_NODE_TYPES = {"CONDITION", "WAIT_DELAY"}
44
+
45
+
46
+ def validate_graph(builder_graph: dict[str, Any]) -> list[dict[str, Any]]:
47
+ """
48
+ Validate a builder_graph_json.
49
+ Returns a list of validation errors: [{node_id, field, message}].
50
+ Empty list means valid.
51
+ """
52
+ errors: list[dict[str, Any]] = []
53
+ nodes: list[dict] = builder_graph.get("nodes", [])
54
+ edges: list[dict] = builder_graph.get("edges", [])
55
+
56
+ if not nodes:
57
+ errors.append({"node_id": None, "field": "graph", "message": "Flow must have at least one node."})
58
+ return errors
59
+
60
+ # Identify trigger nodes
61
+ trigger_nodes = [n for n in nodes if _get_node_type(n) in TRIGGER_NODE_TYPES]
62
+ action_nodes = [n for n in nodes if _get_node_type(n) not in TRIGGER_NODE_TYPES]
63
+
64
+ # Must have exactly one trigger
65
+ if len(trigger_nodes) == 0:
66
+ errors.append({
67
+ "node_id": None,
68
+ "field": "trigger",
69
+ "message": "Flow must have exactly one trigger node (e.g. WhatsApp Message, Meta Lead Ads)."
70
+ })
71
+ elif len(trigger_nodes) > 1:
72
+ for tn in trigger_nodes[1:]:
73
+ errors.append({
74
+ "node_id": tn.get("id"),
75
+ "field": "trigger",
76
+ "message": "Flow can only have one trigger node."
77
+ })
78
+
79
+ # Check builder-only (unsupported runtime) node types
80
+ for node in nodes:
81
+ nt = _get_node_type(node)
82
+ if nt in BUILDER_ONLY_NODE_TYPES:
83
+ errors.append({
84
+ "node_id": node.get("id"),
85
+ "field": "type",
86
+ "message": f"Node type '{nt}' is not yet supported by the runtime engine — cannot publish. Remove it or replace it with a supported action."
87
+ })
88
+
89
+ # Build reachability set from trigger (BFS/DFS)
90
+ if trigger_nodes:
91
+ trigger_id = trigger_nodes[0].get("id")
92
+ reachable = _reachable_nodes(trigger_id, edges)
93
+ for node in action_nodes:
94
+ nid = node.get("id")
95
+ if nid not in reachable:
96
+ errors.append({
97
+ "node_id": nid,
98
+ "field": "connection",
99
+ "message": "This node is not connected to the trigger. Every node must be reachable from the trigger."
100
+ })
101
+
102
+ # Trigger must have at least one outgoing edge
103
+ if trigger_nodes:
104
+ trigger_id = trigger_nodes[0].get("id")
105
+ outgoing = [e for e in edges if e.get("source") == trigger_id]
106
+ if not outgoing and action_nodes:
107
+ errors.append({
108
+ "node_id": trigger_id,
109
+ "field": "edges",
110
+ "message": "Trigger node has no outgoing connections. Connect it to at least one action."
111
+ })
112
+
113
+ # Per-type config validation
114
+ for node in nodes:
115
+ nt = _get_node_type(node)
116
+ config = node.get("data", {}).get("config", {})
117
+ nid = node.get("id")
118
+
119
+ if nt == "AI_REPLY":
120
+ goal = (config.get("goal") or "").strip()
121
+ if not goal:
122
+ errors.append({
123
+ "node_id": nid,
124
+ "field": "config.goal",
125
+ "message": "AI Reply node requires a 'Goal' — describe what the AI should accomplish."
126
+ })
127
+
128
+ elif nt == "SEND_MESSAGE":
129
+ content = (config.get("content") or "").strip()
130
+ if not content:
131
+ errors.append({
132
+ "node_id": nid,
133
+ "field": "config.content",
134
+ "message": "Send Message node requires message content."
135
+ })
136
+
137
+ elif nt == "TAG_CONTACT":
138
+ tag = (config.get("tag") or "").strip()
139
+ if not tag:
140
+ errors.append({
141
+ "node_id": nid,
142
+ "field": "config.tag",
143
+ "message": "Tag Contact node requires a tag name."
144
+ })
145
+
146
+ return errors
147
+
148
+
149
+ def translate(builder_graph: dict[str, Any]) -> dict[str, Any]:
150
+ """
151
+ Translate builder_graph_json to runtime definition_json.
152
+ Must only be called after validate_graph() returns an empty list.
153
+ Returns the definition_json ready for FlowVersion.definition_json.
154
+ """
155
+ nodes_in = builder_graph.get("nodes", [])
156
+ edges_in = builder_graph.get("edges", [])
157
+
158
+ runtime_nodes = []
159
+ start_node_id: str | None = None
160
+
161
+ for node in nodes_in:
162
+ nid = node.get("id")
163
+ node_type = _get_node_type(node)
164
+ data = node.get("data", {})
165
+ config = data.get("config", {})
166
+
167
+ if node_type in TRIGGER_NODE_TYPES:
168
+ # Trigger node → runtime TRIGGER type
169
+ runtime_nodes.append({
170
+ "id": nid,
171
+ "type": "TRIGGER",
172
+ "config": {
173
+ "trigger_type": node_type,
174
+ "platform": data.get("platform", config.get("platform", "whatsapp")),
175
+ "keywords": data.get("keywords", config.get("keywords", [])),
176
+ },
177
+ })
178
+ start_node_id = nid
179
+ else:
180
+ # Action node → preserve type and config
181
+ runtime_nodes.append({
182
+ "id": nid,
183
+ "type": node_type,
184
+ "config": config,
185
+ })
186
+
187
+ runtime_edges = []
188
+ for edge in edges_in:
189
+ runtime_edges.append({
190
+ "id": edge.get("id"),
191
+ "source_node_id": edge.get("source"),
192
+ "target_node_id": edge.get("target"),
193
+ "source_handle": edge.get("sourceHandle"),
194
+ })
195
+
196
+ return {
197
+ "start_node_id": start_node_id,
198
+ "nodes": runtime_nodes,
199
+ "edges": runtime_edges,
200
+ }
201
+
202
+
203
+ def simulate(
204
+ builder_graph: dict[str, Any],
205
+ mock_payload: dict[str, Any] | None = None,
206
+ ) -> dict[str, Any]:
207
+ """
208
+ Dry-run traversal of builder_graph without any DB access or dispatch.
209
+ Returns a preview of what each node would do.
210
+ No Message rows, no provider adapter calls, no side effects.
211
+ """
212
+ nodes_in = builder_graph.get("nodes", [])
213
+ edges_in = builder_graph.get("edges", [])
214
+
215
+ node_map = {n.get("id"): n for n in nodes_in}
216
+ edge_map: dict[str, list[str]] = {}
217
+ for edge in edges_in:
218
+ src = edge.get("source")
219
+ if src not in edge_map:
220
+ edge_map[src] = []
221
+ edge_map[src].append(edge.get("target"))
222
+
223
+ # Find trigger node
224
+ trigger_nodes = [n for n in nodes_in if _get_node_type(n) in TRIGGER_NODE_TYPES]
225
+ if not trigger_nodes:
226
+ return {
227
+ "steps": [],
228
+ "dispatch_blocked": True,
229
+ "message": "No trigger node found. Add a trigger to simulate the flow.",
230
+ }
231
+
232
+ steps = []
233
+ current_id: str | None = trigger_nodes[0].get("id")
234
+ visited: set[str] = set()
235
+
236
+ while current_id and current_id not in visited:
237
+ visited.add(current_id)
238
+ node = node_map.get(current_id)
239
+ if not node:
240
+ break
241
+
242
+ node_type = _get_node_type(node)
243
+ config = node.get("data", {}).get("config", {})
244
+
245
+ step: dict[str, Any] = {
246
+ "node_id": current_id,
247
+ "node_type": node_type,
248
+ "dispatch_blocked": True,
249
+ }
250
+
251
+ if node_type in TRIGGER_NODE_TYPES:
252
+ step["description"] = f"Trigger: {node_type}"
253
+ if mock_payload:
254
+ step["mock_payload"] = mock_payload
255
+ elif node_type == "AI_REPLY":
256
+ step["description"] = "AI Reply — would generate a response using your workspace prompt configuration."
257
+ step["goal"] = config.get("goal", "(no goal set)")
258
+ step["would_dispatch"] = False
259
+ elif node_type == "SEND_MESSAGE":
260
+ content = config.get("content", "")
261
+ step["description"] = "Send Message — would send the following message."
262
+ step["would_send"] = content
263
+ step["would_dispatch"] = False
264
+ elif node_type == "HUMAN_HANDOVER":
265
+ msg = config.get("announcement", "A team member will contact you.")
266
+ step["description"] = "Human Handover — would transfer conversation to a human agent."
267
+ step["announcement"] = msg
268
+ step["would_dispatch"] = False
269
+ elif node_type == "TAG_CONTACT":
270
+ step["description"] = f"Tag Contact — would apply tag '{config.get('tag', '')}' to the contact."
271
+ elif node_type == "ZOHO_UPSERT_LEAD":
272
+ step["description"] = "Zoho CRM — would upsert the contact as a lead in Zoho CRM."
273
+ elif node_type in BUILDER_ONLY_NODE_TYPES:
274
+ step["description"] = f"{node_type} — not yet supported by runtime. This node would be skipped."
275
+ step["warning"] = "Builder-only node type"
276
+ else:
277
+ step["description"] = f"Unknown node type: {node_type}"
278
+
279
+ steps.append(step)
280
+
281
+ next_nodes = edge_map.get(current_id, [])
282
+ current_id = next_nodes[0] if next_nodes else None
283
+
284
+ return {
285
+ "steps": steps,
286
+ "dispatch_blocked": True,
287
+ "message": f"Simulation complete. {len(steps)} step(s) previewed. No messages were sent.",
288
+ }
289
+
290
+
291
+ def _get_node_type(node: dict) -> str:
292
+ """Extract the business logic node type from a builder node."""
293
+ data = node.get("data", {})
294
+ return data.get("nodeType", "")
295
+
296
+
297
+ def _reachable_nodes(start_id: str, edges: list[dict]) -> set[str]:
298
+ """BFS to find all node IDs reachable from start_id via edges."""
299
+ edge_map: dict[str, list[str]] = {}
300
+ for edge in edges:
301
+ src = edge.get("source")
302
+ if src not in edge_map:
303
+ edge_map[src] = []
304
+ tgt = edge.get("target")
305
+ if tgt:
306
+ edge_map[src].append(tgt)
307
+
308
+ visited: set[str] = set()
309
+ queue = [start_id]
310
+ while queue:
311
+ node_id = queue.pop(0)
312
+ if node_id in visited:
313
+ continue
314
+ visited.add(node_id)
315
+ queue.extend(edge_map.get(node_id, []))
316
+ return visited
backend/app/models/models.py CHANGED
@@ -133,7 +133,9 @@ class Flow(WorkspaceScopedModel, table=True):
133
  name: str = Field(index=True)
134
  description: Optional[str] = None
135
  status: FlowStatus = Field(default=FlowStatus.DRAFT)
136
-
 
 
137
  # Relationships
138
  nodes: List["FlowNode"] = Relationship(back_populates="flow")
139
  edges: List["FlowEdge"] = Relationship(back_populates="flow")
@@ -525,3 +527,60 @@ class SystemSettings(BaseIDModel, table=True):
525
  settings_json: Dict[str, Any] = Field(sa_column=Column(JSON))
526
  version: int = Field(default=1)
527
  updated_by_user_id: Optional[UUID] = Field(default=None, foreign_key="user.id")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  name: str = Field(index=True)
134
  description: Optional[str] = None
135
  status: FlowStatus = Field(default=FlowStatus.DRAFT)
136
+ # Points to the active published FlowVersion; FK constraint added by Alembic migration
137
+ published_version_id: Optional[UUID] = Field(default=None, index=True)
138
+
139
  # Relationships
140
  nodes: List["FlowNode"] = Relationship(back_populates="flow")
141
  edges: List["FlowEdge"] = Relationship(back_populates="flow")
 
527
  settings_json: Dict[str, Any] = Field(sa_column=Column(JSON))
528
  version: int = Field(default=1)
529
  updated_by_user_id: Optional[UUID] = Field(default=None, foreign_key="user.id")
530
+
531
+
532
+ # --- Builder v2: Draft Storage (Mission 27) ---
533
+
534
+ class FlowDraft(BaseIDModel, table=True):
535
+ """Editable builder graph for a flow. One draft per flow. Not used by runtime."""
536
+ workspace_id: UUID = Field(index=True)
537
+ flow_id: UUID = Field(foreign_key="flow.id")
538
+ builder_graph_json: Dict[str, Any] = Field(sa_column=Column(JSON))
539
+ updated_by_user_id: Optional[UUID] = Field(default=None)
540
+ last_validation_errors: Optional[Dict[str, Any]] = Field(
541
+ default=None, sa_column=Column(JSON, nullable=True)
542
+ )
543
+
544
+ __table_args__ = (
545
+ UniqueConstraint("flow_id", name="uq_flowdraft_flow_id"),
546
+ Index("idx_flowdraft_ws_updated", "workspace_id", "updated_at"),
547
+ )
548
+
549
+
550
+ # --- Template Catalog (Mission 27) ---
551
+
552
+ class AutomationTemplate(BaseIDModel, table=True):
553
+ """Global, admin-managed automation template. Workspace users can browse and clone."""
554
+ slug: str = Field(unique=True, index=True)
555
+ name: str = Field(index=True)
556
+ description: Optional[str] = None
557
+ category: str = Field(default="general", index=True)
558
+ industry_tags: List[str] = Field(default_factory=list, sa_column=Column(JSON))
559
+ platforms: List[str] = Field(default_factory=list, sa_column=Column(JSON))
560
+ required_integrations: List[str] = Field(default_factory=list, sa_column=Column(JSON))
561
+ is_featured: bool = Field(default=False, index=True)
562
+ is_active: bool = Field(default=True, index=True)
563
+ created_by_admin_id: Optional[UUID] = Field(default=None)
564
+
565
+ __table_args__ = (
566
+ Index("idx_template_active_featured", "is_active", "is_featured"),
567
+ )
568
+
569
+
570
+ class AutomationTemplateVersion(BaseIDModel, table=True):
571
+ """Immutable snapshot of a template version. Published versions are cloneable."""
572
+ template_id: UUID = Field(foreign_key="automationtemplate.id", index=True)
573
+ version_number: int
574
+ builder_graph_json: Dict[str, Any] = Field(sa_column=Column(JSON))
575
+ translated_definition_json: Optional[Dict[str, Any]] = Field(
576
+ default=None, sa_column=Column(JSON, nullable=True)
577
+ )
578
+ changelog: Optional[str] = None
579
+ created_by_admin_id: Optional[UUID] = Field(default=None)
580
+ is_published: bool = Field(default=False, index=True)
581
+ published_at: Optional[datetime] = Field(default=None)
582
+
583
+ __table_args__ = (
584
+ Index("idx_atv_template_ver", "template_id", "version_number"),
585
+ Index("idx_atv_template_published", "template_id", "is_published"),
586
+ )
backend/app/workers/tasks.py CHANGED
@@ -155,18 +155,34 @@ def process_webhook_event(event_id: str):
155
  )
156
  session.add(inbound_msg)
157
 
158
- # 3. Find Matching Published Flow
159
- flow_query = select(FlowVersion).join(Flow).where(
 
160
  Flow.workspace_id == event.workspace_id,
161
- FlowVersion.is_published == True
162
- ).order_by(FlowVersion.created_at.desc())
 
 
 
 
163
 
164
- flow_version = (await session.execute(flow_query)).scalars().first()
 
 
 
 
 
 
165
 
166
  if flow_version:
167
  # 4. Create Execution Instance
168
  nodes = flow_version.definition_json.get("nodes", [])
169
- start_node = next((n for n in nodes if n.get("type") == "TRIGGER"), None)
 
 
 
 
 
170
  if not start_node and nodes:
171
  start_node = nodes[0]
172
 
 
155
  )
156
  session.add(inbound_msg)
157
 
158
+ # 3. Find Matching Published Flow (prefer published_version_id pointer)
159
+ flow_version = None
160
+ flow_query = select(Flow).where(
161
  Flow.workspace_id == event.workspace_id,
162
+ Flow.status == "published",
163
+ Flow.published_version_id != None, # noqa: E711
164
+ ).limit(1)
165
+ flow_result = (await session.execute(flow_query)).scalars().first()
166
+ if flow_result and flow_result.published_version_id:
167
+ flow_version = await session.get(FlowVersion, flow_result.published_version_id)
168
 
169
+ # Fallback: legacy flows published before Mission 27 (published_version_id not set)
170
+ if not flow_version:
171
+ legacy_query = select(FlowVersion).join(Flow).where(
172
+ Flow.workspace_id == event.workspace_id,
173
+ FlowVersion.is_published == True
174
+ ).order_by(FlowVersion.created_at.desc())
175
+ flow_version = (await session.execute(legacy_query)).scalars().first()
176
 
177
  if flow_version:
178
  # 4. Create Execution Instance
179
  nodes = flow_version.definition_json.get("nodes", [])
180
+ # Use start_node_id if available (Mission 27 format), else find TRIGGER node
181
+ start_node_id = flow_version.definition_json.get("start_node_id")
182
+ if start_node_id:
183
+ start_node = next((n for n in nodes if n.get("id") == start_node_id), None)
184
+ else:
185
+ start_node = next((n for n in nodes if n.get("type") == "TRIGGER"), None)
186
  if not start_node and nodes:
187
  start_node = nodes[0]
188
 
backend/main.py CHANGED
@@ -4,7 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware
4
  from sqlmodel import SQLModel
5
  from app.core.config import settings
6
  from app.core.db import engine
7
- from app.api.v1 import auth, workspaces, health, prompt_config, test_chat, integrations, webhooks, automations, knowledge, analytics
8
  from app.core.seed import seed_modules, seed_plans, seed_system_settings
9
  from app.api.v1.dispatch import router as dispatch_router
10
  from app.api.v1.inbox import router as inbox_router
@@ -147,6 +147,7 @@ app.include_router(test_chat.router, prefix=f"{settings.API_V1_STR}/test-chat",
147
  app.include_router(integrations.router, prefix=f"{settings.API_V1_STR}/integrations", tags=["integrations"])
148
  app.include_router(webhooks.router, prefix=f"{settings.API_V1_STR}/webhooks", tags=["webhooks"])
149
  app.include_router(automations.router, prefix=f"{settings.API_V1_STR}/automations", tags=["automations"])
 
150
  app.include_router(knowledge.router, prefix=f"{settings.API_V1_STR}/knowledge", tags=["knowledge"])
151
  app.include_router(dispatch_router, prefix=f"{settings.API_V1_STR}/dispatch", tags=["dispatch"])
152
  app.include_router(inbox_router, prefix=f"{settings.API_V1_STR}/inbox", tags=["inbox"])
 
4
  from sqlmodel import SQLModel
5
  from app.core.config import settings
6
  from app.core.db import engine
7
+ from app.api.v1 import auth, workspaces, health, prompt_config, test_chat, integrations, webhooks, automations, knowledge, analytics, templates
8
  from app.core.seed import seed_modules, seed_plans, seed_system_settings
9
  from app.api.v1.dispatch import router as dispatch_router
10
  from app.api.v1.inbox import router as inbox_router
 
147
  app.include_router(integrations.router, prefix=f"{settings.API_V1_STR}/integrations", tags=["integrations"])
148
  app.include_router(webhooks.router, prefix=f"{settings.API_V1_STR}/webhooks", tags=["webhooks"])
149
  app.include_router(automations.router, prefix=f"{settings.API_V1_STR}/automations", tags=["automations"])
150
+ app.include_router(templates.router, prefix=f"{settings.API_V1_STR}/templates", tags=["templates"])
151
  app.include_router(knowledge.router, prefix=f"{settings.API_V1_STR}/knowledge", tags=["knowledge"])
152
  app.include_router(dispatch_router, prefix=f"{settings.API_V1_STR}/dispatch", tags=["dispatch"])
153
  app.include_router(inbox_router, prefix=f"{settings.API_V1_STR}/inbox", tags=["inbox"])
backend/tests/test_automation.py CHANGED
@@ -1,41 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  import pytest
2
  from httpx import AsyncClient
3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  async def get_auth_headers(client: AsyncClient, email: str) -> dict:
5
  pwd = "password123"
6
- await client.post("/api/v1/auth/signup", json={"email": email, "password": pwd, "full_name": "Inbox Test"})
7
  login_res = await client.post("/api/v1/auth/login", data={"username": email, "password": pwd})
8
  token = login_res.json()["data"]["access_token"]
9
-
10
  ws_res = await client.get("/api/v1/workspaces", headers={"Authorization": f"Bearer {token}"})
11
  ws_id = ws_res.json()["data"][0]["id"]
12
  return {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id}
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  @pytest.mark.asyncio
15
- async def test_automation_publish(async_client: AsyncClient):
16
- headers = await get_auth_headers(async_client, "auto_test@example.com")
17
-
18
- # Must use /from-builder
19
- flow_payload = {
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)
27
- assert start_res.status_code == 200, f"Create Failed: {start_res.text}"
28
- flow_data = start_res.json()["data"]
29
- flow_id = flow_data["flow_id"]
30
-
31
- # 2. Publish
32
- pub_payload = {
33
- "definition": {"nodes": [], "edges": []},
34
- "system_prompt": "You are a test bot",
35
- "commit_message": "Initial publish"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  }
37
-
38
- pub_res = await async_client.post(f"/api/v1/automations/{flow_id}/publish", json=pub_payload, headers=headers)
39
- assert pub_res.status_code == 200, f"Publish Failed: {pub_res.text}"
40
- version_data = pub_res.json()["data"]
41
- assert version_data["status"] == "published"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Integration tests for the automation endpoints (Mission 27 — Builder v2).
3
+
4
+ Tests cover:
5
+ - Backward compatibility of /from-builder
6
+ - Blank flow creation
7
+ - Draft get/save/validate
8
+ - Publish (validation gate, FlowVersion creation, published_version_id)
9
+ - Version listing with is_active flag
10
+ - Rollback (creates new version = old snapshot)
11
+ - Simulate (dry-run, no dispatch)
12
+ """
13
  import pytest
14
  from httpx import AsyncClient
15
 
16
+ # ---------------------------------------------------------------------------
17
+ # Helpers
18
+ # ---------------------------------------------------------------------------
19
+
20
+ VALID_BUILDER_GRAPH = {
21
+ "nodes": [
22
+ {
23
+ "id": "trigger-1",
24
+ "type": "triggerNode",
25
+ "position": {"x": 250, "y": 50},
26
+ "data": {"nodeType": "MESSAGE_INBOUND", "platform": "whatsapp", "config": {}},
27
+ },
28
+ {
29
+ "id": "node-1",
30
+ "type": "actionNode",
31
+ "position": {"x": 250, "y": 220},
32
+ "data": {"nodeType": "AI_REPLY", "config": {"goal": "Help the user", "tasks": []}},
33
+ },
34
+ ],
35
+ "edges": [{"id": "e1", "source": "trigger-1", "target": "node-1"}],
36
+ }
37
+
38
+ INVALID_BUILDER_GRAPH = {
39
+ "nodes": [
40
+ {
41
+ "id": "trigger-1",
42
+ "type": "triggerNode",
43
+ "position": {"x": 250, "y": 50},
44
+ "data": {"nodeType": "MESSAGE_INBOUND", "platform": "whatsapp", "config": {}},
45
+ },
46
+ {
47
+ "id": "node-1",
48
+ "type": "actionNode",
49
+ "position": {"x": 250, "y": 220},
50
+ "data": {"nodeType": "AI_REPLY", "config": {"goal": ""}}, # missing goal
51
+ },
52
+ ],
53
+ "edges": [{"id": "e1", "source": "trigger-1", "target": "node-1"}],
54
+ }
55
+
56
+
57
  async def get_auth_headers(client: AsyncClient, email: str) -> dict:
58
  pwd = "password123"
59
+ await client.post("/api/v1/auth/signup", json={"email": email, "password": pwd, "full_name": "Auto Test"})
60
  login_res = await client.post("/api/v1/auth/login", data={"username": email, "password": pwd})
61
  token = login_res.json()["data"]["access_token"]
 
62
  ws_res = await client.get("/api/v1/workspaces", headers={"Authorization": f"Bearer {token}"})
63
  ws_id = ws_res.json()["data"][0]["id"]
64
  return {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id}
65
 
66
+
67
+ async def create_blank_flow(client: AsyncClient, headers: dict, name: str = "Test Flow") -> str:
68
+ """Helper: create blank flow, return flow_id."""
69
+ res = await client.post("/api/v1/automations", json={"name": name}, headers=headers)
70
+ assert res.status_code == 200, f"Create flow failed: {res.text}"
71
+ return res.json()["data"]["flow_id"]
72
+
73
+
74
+ async def save_draft(client: AsyncClient, headers: dict, flow_id: str, graph: dict) -> None:
75
+ """Helper: save draft."""
76
+ res = await client.put(
77
+ f"/api/v1/automations/{flow_id}/draft",
78
+ json={"builder_graph_json": graph},
79
+ headers=headers,
80
+ )
81
+ assert res.status_code == 200, f"Save draft failed: {res.text}"
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Backward Compatibility
86
+ # ---------------------------------------------------------------------------
87
+
88
  @pytest.mark.asyncio
89
+ async def test_from_builder_backward_compat(async_client: AsyncClient):
90
+ """POST /from-builder with publish=True still works (backward compat)."""
91
+ headers = await get_auth_headers(async_client, "compat_test@example.com")
92
+
93
+ payload = {
94
+ "name": "Compat Flow",
95
+ "description": "Backward compat test",
96
+ "steps": [],
97
  "trigger": {"type": "MESSAGE_INBOUND", "platform": "WHATSAPP", "keywords": []},
98
+ "publish": True,
99
  }
100
+ res = await async_client.post("/api/v1/automations/from-builder", json=payload, headers=headers)
101
+ assert res.status_code == 200, f"from-builder failed: {res.text}"
102
+ data = res.json()["data"]
103
+ assert "flow_id" in data
104
+ assert data["version"] == 1
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Flow Creation
109
+ # ---------------------------------------------------------------------------
110
+
111
+ @pytest.mark.asyncio
112
+ async def test_create_blank_flow(async_client: AsyncClient):
113
+ """POST /automations creates a blank DRAFT flow and returns flow_id."""
114
+ headers = await get_auth_headers(async_client, "blank_flow@example.com")
115
+ res = await async_client.post("/api/v1/automations", json={"name": "My Canvas Flow"}, headers=headers)
116
+ assert res.status_code == 200
117
+ assert "flow_id" in res.json()["data"]
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Draft — Get / Save
122
+ # ---------------------------------------------------------------------------
123
+
124
+ @pytest.mark.asyncio
125
+ async def test_get_draft_empty(async_client: AsyncClient):
126
+ """GET /draft on a new flow returns builder_graph_json=null."""
127
+ headers = await get_auth_headers(async_client, "draft_empty@example.com")
128
+ flow_id = await create_blank_flow(async_client, headers)
129
+
130
+ res = await async_client.get(f"/api/v1/automations/{flow_id}/draft", headers=headers)
131
+ assert res.status_code == 200
132
+ data = res.json()["data"]
133
+ assert data["builder_graph_json"] is None
134
+ assert data["last_validation_errors"] is None
135
+
136
+
137
+ @pytest.mark.asyncio
138
+ async def test_put_and_get_draft(async_client: AsyncClient):
139
+ """PUT draft then GET returns the same builder_graph_json."""
140
+ headers = await get_auth_headers(async_client, "draft_save@example.com")
141
+ flow_id = await create_blank_flow(async_client, headers)
142
+
143
+ await save_draft(async_client, headers, flow_id, VALID_BUILDER_GRAPH)
144
+
145
+ res = await async_client.get(f"/api/v1/automations/{flow_id}/draft", headers=headers)
146
+ assert res.status_code == 200
147
+ data = res.json()["data"]
148
+ assert data["builder_graph_json"] is not None
149
+ assert len(data["builder_graph_json"]["nodes"]) == 2
150
+ assert data["updated_at"] is not None
151
+
152
+
153
+ @pytest.mark.asyncio
154
+ async def test_put_draft_twice_upserts(async_client: AsyncClient):
155
+ """Saving draft twice updates in-place (no duplicate rows)."""
156
+ headers = await get_auth_headers(async_client, "draft_upsert@example.com")
157
+ flow_id = await create_blank_flow(async_client, headers)
158
+
159
+ await save_draft(async_client, headers, flow_id, VALID_BUILDER_GRAPH)
160
+
161
+ updated_graph = dict(VALID_BUILDER_GRAPH)
162
+ updated_graph["nodes"] = [VALID_BUILDER_GRAPH["nodes"][0]] # only trigger
163
+ updated_graph["edges"] = []
164
+ await save_draft(async_client, headers, flow_id, updated_graph)
165
+
166
+ res = await async_client.get(f"/api/v1/automations/{flow_id}/draft", headers=headers)
167
+ draft = res.json()["data"]["builder_graph_json"]
168
+ assert len(draft["nodes"]) == 1 # reflects second save
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Draft — Validate
173
+ # ---------------------------------------------------------------------------
174
+
175
+ @pytest.mark.asyncio
176
+ async def test_validate_draft_no_draft_returns_error(async_client: AsyncClient):
177
+ """Validate without a saved draft returns error message."""
178
+ headers = await get_auth_headers(async_client, "validate_nodraft@example.com")
179
+ flow_id = await create_blank_flow(async_client, headers)
180
+
181
+ res = await async_client.post(f"/api/v1/automations/{flow_id}/draft/validate", headers=headers)
182
+ assert res.status_code == 200
183
+ assert res.json()["success"] is False
184
+
185
+
186
+ @pytest.mark.asyncio
187
+ async def test_validate_draft_invalid(async_client: AsyncClient):
188
+ """Validate draft with missing AI_REPLY goal returns structured errors."""
189
+ headers = await get_auth_headers(async_client, "validate_invalid@example.com")
190
+ flow_id = await create_blank_flow(async_client, headers)
191
+ await save_draft(async_client, headers, flow_id, INVALID_BUILDER_GRAPH)
192
+
193
+ res = await async_client.post(f"/api/v1/automations/{flow_id}/draft/validate", headers=headers)
194
+ assert res.status_code == 200
195
+ body = res.json()
196
+ assert body["success"] is True # request succeeded, validation failed
197
+ data = body["data"]
198
+ assert data["valid"] is False
199
+ assert len(data["errors"]) >= 1
200
+ assert any(e["node_id"] == "node-1" for e in data["errors"])
201
+
202
+
203
+ @pytest.mark.asyncio
204
+ async def test_validate_draft_valid(async_client: AsyncClient):
205
+ """Validate draft with correct graph returns valid=True and no errors."""
206
+ headers = await get_auth_headers(async_client, "validate_valid@example.com")
207
+ flow_id = await create_blank_flow(async_client, headers)
208
+ await save_draft(async_client, headers, flow_id, VALID_BUILDER_GRAPH)
209
+
210
+ res = await async_client.post(f"/api/v1/automations/{flow_id}/draft/validate", headers=headers)
211
+ assert res.status_code == 200
212
+ data = res.json()["data"]
213
+ assert data["valid"] is True
214
+ assert data["errors"] == []
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Publish
219
+ # ---------------------------------------------------------------------------
220
+
221
+ @pytest.mark.asyncio
222
+ async def test_publish_without_draft_fails(async_client: AsyncClient):
223
+ """Publish without a draft returns an error (not 500)."""
224
+ headers = await get_auth_headers(async_client, "publish_nodraft@example.com")
225
+ flow_id = await create_blank_flow(async_client, headers)
226
+
227
+ res = await async_client.post(f"/api/v1/automations/{flow_id}/publish", headers=headers)
228
+ assert res.status_code == 200
229
+ assert res.json()["success"] is False
230
+
231
+
232
+ @pytest.mark.asyncio
233
+ async def test_publish_invalid_draft_blocked(async_client: AsyncClient):
234
+ """Publish with invalid draft does NOT create a FlowVersion. Returns errors."""
235
+ headers = await get_auth_headers(async_client, "publish_invalid@example.com")
236
+ flow_id = await create_blank_flow(async_client, headers)
237
+ await save_draft(async_client, headers, flow_id, INVALID_BUILDER_GRAPH)
238
+
239
+ res = await async_client.post(f"/api/v1/automations/{flow_id}/publish", headers=headers)
240
+ assert res.status_code == 200
241
+ data = res.json()["data"]
242
+ assert data["success"] is False
243
+ assert data["published"] is False
244
+ assert len(data["errors"]) >= 1
245
+
246
+
247
+ @pytest.mark.asyncio
248
+ async def test_publish_valid_creates_flow_version(async_client: AsyncClient):
249
+ """
250
+ Valid draft publish:
251
+ - Creates FlowVersion
252
+ - Sets flow.published_version_id
253
+ - Returns success=True + version_number
254
+ - Flow status becomes PUBLISHED
255
+ """
256
+ headers = await get_auth_headers(async_client, "publish_valid@example.com")
257
+ flow_id = await create_blank_flow(async_client, headers)
258
+ await save_draft(async_client, headers, flow_id, VALID_BUILDER_GRAPH)
259
+
260
+ res = await async_client.post(f"/api/v1/automations/{flow_id}/publish", headers=headers)
261
+ assert res.status_code == 200
262
+ data = res.json()["data"]
263
+ assert data["success"] is True
264
+ assert data["published"] is True
265
+ assert data["version_number"] == 1
266
+ assert data["version_id"] is not None
267
+ assert data["published_at"] is not None
268
+
269
+ # Flow should now be PUBLISHED
270
+ flow_res = await async_client.get(f"/api/v1/automations/{flow_id}", headers=headers)
271
+ flow = flow_res.json()["data"]
272
+ assert flow["status"] == "published"
273
+ assert flow["published_version_id"] is not None
274
+
275
+
276
+ @pytest.mark.asyncio
277
+ async def test_publish_increments_version_number(async_client: AsyncClient):
278
+ """Publishing twice produces version 1 then version 2."""
279
+ headers = await get_auth_headers(async_client, "publish_v2@example.com")
280
+ flow_id = await create_blank_flow(async_client, headers)
281
+ await save_draft(async_client, headers, flow_id, VALID_BUILDER_GRAPH)
282
+
283
+ res1 = await async_client.post(f"/api/v1/automations/{flow_id}/publish", headers=headers)
284
+ assert res1.json()["data"]["version_number"] == 1
285
+
286
+ # Save draft again and publish
287
+ await save_draft(async_client, headers, flow_id, VALID_BUILDER_GRAPH)
288
+ res2 = await async_client.post(f"/api/v1/automations/{flow_id}/publish", headers=headers)
289
+ assert res2.json()["data"]["version_number"] == 2
290
+
291
+
292
+ # ---------------------------------------------------------------------------
293
+ # Versions
294
+ # ---------------------------------------------------------------------------
295
+
296
+ @pytest.mark.asyncio
297
+ async def test_versions_list(async_client: AsyncClient):
298
+ """GET /versions returns all versions, newest first, with is_active flag."""
299
+ headers = await get_auth_headers(async_client, "versions_list@example.com")
300
+ flow_id = await create_blank_flow(async_client, headers)
301
+ await save_draft(async_client, headers, flow_id, VALID_BUILDER_GRAPH)
302
+
303
+ # Publish v1
304
+ await async_client.post(f"/api/v1/automations/{flow_id}/publish", headers=headers)
305
+ # Publish v2
306
+ await save_draft(async_client, headers, flow_id, VALID_BUILDER_GRAPH)
307
+ await async_client.post(f"/api/v1/automations/{flow_id}/publish", headers=headers)
308
+
309
+ res = await async_client.get(f"/api/v1/automations/{flow_id}/versions", headers=headers)
310
+ assert res.status_code == 200
311
+ versions = res.json()["data"]
312
+ assert len(versions) == 2
313
+
314
+ # Newest (v2) should be first and is_active
315
+ assert versions[0]["version_number"] == 2
316
+ assert versions[0]["is_active"] is True
317
+
318
+ # v1 is not active
319
+ assert versions[1]["version_number"] == 1
320
+ assert versions[1]["is_active"] is False
321
+
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # Rollback
325
+ # ---------------------------------------------------------------------------
326
+
327
+ @pytest.mark.asyncio
328
+ async def test_rollback_creates_new_version(async_client: AsyncClient):
329
+ """
330
+ Rollback to v1 creates v3 (new snapshot) with same definition as v1.
331
+ flow.published_version_id points to v3.
332
+ """
333
+ headers = await get_auth_headers(async_client, "rollback@example.com")
334
+ flow_id = await create_blank_flow(async_client, headers)
335
+ await save_draft(async_client, headers, flow_id, VALID_BUILDER_GRAPH)
336
+
337
+ # Publish v1
338
+ pub1 = await async_client.post(f"/api/v1/automations/{flow_id}/publish", headers=headers)
339
+ v1_id = pub1.json()["data"]["version_id"]
340
+
341
+ # Publish v2
342
+ await save_draft(async_client, headers, flow_id, VALID_BUILDER_GRAPH)
343
+ await async_client.post(f"/api/v1/automations/{flow_id}/publish", headers=headers)
344
+
345
+ # Rollback to v1
346
+ rb_res = await async_client.post(
347
+ f"/api/v1/automations/{flow_id}/rollback/{v1_id}", headers=headers
348
+ )
349
+ assert rb_res.status_code == 200
350
+ rb_data = rb_res.json()["data"]
351
+ assert rb_data["new_version_number"] == 3
352
+ assert rb_data["rolled_back_to"] == 1
353
+
354
+ # Confirm versions list shows 3 items and v3 is active
355
+ ver_res = await async_client.get(f"/api/v1/automations/{flow_id}/versions", headers=headers)
356
+ versions = ver_res.json()["data"]
357
+ assert len(versions) == 3
358
+ assert versions[0]["is_active"] is True
359
+ assert versions[0]["version_number"] == 3
360
+
361
+
362
+ # ---------------------------------------------------------------------------
363
+ # Simulate
364
+ # ---------------------------------------------------------------------------
365
+
366
+ @pytest.mark.asyncio
367
+ async def test_simulate_no_draft_returns_error(async_client: AsyncClient):
368
+ """Simulate without a draft returns an error response."""
369
+ headers = await get_auth_headers(async_client, "sim_nodraft@example.com")
370
+ flow_id = await create_blank_flow(async_client, headers)
371
+
372
+ res = await async_client.post(f"/api/v1/automations/{flow_id}/simulate", json={}, headers=headers)
373
+ assert res.status_code == 200
374
+ assert res.json()["success"] is False
375
+
376
+
377
+ @pytest.mark.asyncio
378
+ async def test_simulate_invalid_draft_returns_errors(async_client: AsyncClient):
379
+ """Simulate with invalid draft returns validation errors, not steps."""
380
+ headers = await get_auth_headers(async_client, "sim_invalid@example.com")
381
+ flow_id = await create_blank_flow(async_client, headers)
382
+ await save_draft(async_client, headers, flow_id, INVALID_BUILDER_GRAPH)
383
+
384
+ res = await async_client.post(f"/api/v1/automations/{flow_id}/simulate", json={}, headers=headers)
385
+ assert res.status_code == 200
386
+ data = res.json()["data"]
387
+ assert data["valid"] is False
388
+ assert len(data["errors"]) >= 1
389
+ assert data["steps"] == []
390
+
391
+
392
+ @pytest.mark.asyncio
393
+ async def test_simulate_no_dispatch(async_client: AsyncClient):
394
+ """
395
+ Simulate with valid draft:
396
+ - Returns steps array (≥1 step)
397
+ - dispatch_blocked is True on result
398
+ - No messages actually sent
399
+ """
400
+ headers = await get_auth_headers(async_client, "sim_valid@example.com")
401
+ flow_id = await create_blank_flow(async_client, headers)
402
+ await save_draft(async_client, headers, flow_id, VALID_BUILDER_GRAPH)
403
+
404
+ res = await async_client.post(
405
+ f"/api/v1/automations/{flow_id}/simulate",
406
+ json={"mock_payload": {"content": "Hello!", "sender": "+1234567890"}},
407
+ headers=headers,
408
+ )
409
+ assert res.status_code == 200
410
+ data = res.json()["data"]
411
+ assert data["valid"] is True
412
+ assert len(data["steps"]) >= 1
413
+ assert data["dispatch_blocked"] is True
414
+ # All steps must have dispatch_blocked=True (no real messages)
415
+ for step in data["steps"]:
416
+ if "would_dispatch" in step:
417
+ assert step["would_dispatch"] is False
418
+
419
+
420
+ @pytest.mark.asyncio
421
+ async def test_simulate_send_message_shows_content(async_client: AsyncClient):
422
+ """Simulate SEND_MESSAGE step exposes would_send field."""
423
+ headers = await get_auth_headers(async_client, "sim_send@example.com")
424
+ flow_id = await create_blank_flow(async_client, headers)
425
+
426
+ graph = {
427
+ "nodes": [
428
+ {
429
+ "id": "trigger-1",
430
+ "type": "triggerNode",
431
+ "position": {"x": 250, "y": 50},
432
+ "data": {"nodeType": "MESSAGE_INBOUND", "platform": "whatsapp", "config": {}},
433
+ },
434
+ {
435
+ "id": "node-1",
436
+ "type": "actionNode",
437
+ "position": {"x": 250, "y": 220},
438
+ "data": {"nodeType": "SEND_MESSAGE", "config": {"content": "Welcome to our service!"}},
439
+ },
440
+ ],
441
+ "edges": [{"id": "e1", "source": "trigger-1", "target": "node-1"}],
442
  }
443
+ await save_draft(async_client, headers, flow_id, graph)
444
+
445
+ res = await async_client.post(f"/api/v1/automations/{flow_id}/simulate", json={}, headers=headers)
446
+ assert res.status_code == 200
447
+ data = res.json()["data"]
448
+ assert data["valid"] is True
449
+ send_step = next(s for s in data["steps"] if s["node_type"] == "SEND_MESSAGE")
450
+ assert send_step["would_send"] == "Welcome to our service!"
451
+
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # Miscellaneous
455
+ # ---------------------------------------------------------------------------
456
+
457
+ @pytest.mark.asyncio
458
+ async def test_update_flow_name(async_client: AsyncClient):
459
+ """PATCH /automations/{id} updates the flow name."""
460
+ headers = await get_auth_headers(async_client, "update_name@example.com")
461
+ flow_id = await create_blank_flow(async_client, headers, "Original Name")
462
+
463
+ res = await async_client.patch(
464
+ f"/api/v1/automations/{flow_id}",
465
+ json={"name": "Updated Name"},
466
+ headers=headers,
467
+ )
468
+ assert res.status_code == 200
469
+ assert res.json()["data"]["name"] == "Updated Name"
470
+
471
+
472
+ @pytest.mark.asyncio
473
+ async def test_delete_flow(async_client: AsyncClient):
474
+ """DELETE /automations/{id} removes the flow."""
475
+ headers = await get_auth_headers(async_client, "delete_flow@example.com")
476
+ flow_id = await create_blank_flow(async_client, headers, "To Be Deleted")
477
+
478
+ res = await async_client.delete(f"/api/v1/automations/{flow_id}", headers=headers)
479
+ assert res.status_code == 200
480
+ assert res.json()["data"]["deleted"] is True
481
+
482
+ # Flow should no longer be found
483
+ get_res = await async_client.get(f"/api/v1/automations/{flow_id}", headers=headers)
484
+ assert get_res.json()["success"] is False
backend/tests/test_builder_translator.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for the builder translator (Mission 27).
3
+ No DB required — pure logic tests.
4
+ """
5
+ import pytest
6
+ from app.domain.builder_translator import validate_graph, translate, simulate
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Helpers
10
+ # ---------------------------------------------------------------------------
11
+
12
+ def make_trigger_node(node_id="trigger-1", node_type="MESSAGE_INBOUND", platform="whatsapp"):
13
+ return {
14
+ "id": node_id,
15
+ "type": "triggerNode",
16
+ "position": {"x": 250, "y": 50},
17
+ "data": {"nodeType": node_type, "platform": platform, "config": {}},
18
+ }
19
+
20
+ def make_action_node(node_id, node_type, config=None):
21
+ return {
22
+ "id": node_id,
23
+ "type": "actionNode",
24
+ "position": {"x": 250, "y": 220},
25
+ "data": {"nodeType": node_type, "config": config or {}},
26
+ }
27
+
28
+ def make_edge(edge_id, source, target):
29
+ return {"id": edge_id, "source": source, "target": target}
30
+
31
+ def make_valid_graph():
32
+ return {
33
+ "nodes": [
34
+ make_trigger_node(),
35
+ make_action_node("node-1", "AI_REPLY", {"goal": "Help user", "tasks": []}),
36
+ ],
37
+ "edges": [make_edge("e1", "trigger-1", "node-1")],
38
+ }
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # validate_graph tests
43
+ # ---------------------------------------------------------------------------
44
+
45
+ def test_validate_graph_valid():
46
+ """A well-formed graph with a trigger and a valid AI_REPLY should pass."""
47
+ errors = validate_graph(make_valid_graph())
48
+ assert errors == []
49
+
50
+
51
+ def test_validate_graph_no_nodes():
52
+ """Empty graph should return an error."""
53
+ errors = validate_graph({"nodes": [], "edges": []})
54
+ assert len(errors) == 1
55
+ assert "node" in errors[0]["message"].lower()
56
+
57
+
58
+ def test_validate_graph_no_trigger():
59
+ """Graph without a trigger node should return an error."""
60
+ graph = {
61
+ "nodes": [make_action_node("node-1", "AI_REPLY", {"goal": "test"})],
62
+ "edges": [],
63
+ }
64
+ errors = validate_graph(graph)
65
+ assert any("trigger" in e["message"].lower() for e in errors)
66
+
67
+
68
+ def test_validate_graph_multiple_triggers():
69
+ """Graph with two trigger nodes should return an error for the second."""
70
+ graph = {
71
+ "nodes": [
72
+ make_trigger_node("trigger-1"),
73
+ make_trigger_node("trigger-2"),
74
+ make_action_node("node-1", "SEND_MESSAGE", {"content": "Hello"}),
75
+ ],
76
+ "edges": [
77
+ make_edge("e1", "trigger-1", "node-1"),
78
+ make_edge("e2", "trigger-2", "node-1"),
79
+ ],
80
+ }
81
+ errors = validate_graph(graph)
82
+ assert any("one trigger" in e["message"].lower() for e in errors)
83
+
84
+
85
+ def test_validate_graph_ai_reply_missing_goal():
86
+ """AI_REPLY node without goal should return a field-specific error."""
87
+ graph = {
88
+ "nodes": [
89
+ make_trigger_node(),
90
+ make_action_node("node-1", "AI_REPLY", {"goal": ""}),
91
+ ],
92
+ "edges": [make_edge("e1", "trigger-1", "node-1")],
93
+ }
94
+ errors = validate_graph(graph)
95
+ assert any(e["node_id"] == "node-1" and "goal" in e["field"] for e in errors)
96
+
97
+
98
+ def test_validate_graph_send_message_missing_content():
99
+ """SEND_MESSAGE without content should return an error."""
100
+ graph = {
101
+ "nodes": [
102
+ make_trigger_node(),
103
+ make_action_node("node-1", "SEND_MESSAGE", {"content": ""}),
104
+ ],
105
+ "edges": [make_edge("e1", "trigger-1", "node-1")],
106
+ }
107
+ errors = validate_graph(graph)
108
+ assert any(e["node_id"] == "node-1" and "content" in e["field"] for e in errors)
109
+
110
+
111
+ def test_validate_graph_tag_contact_missing_tag():
112
+ """TAG_CONTACT without tag should return an error."""
113
+ graph = {
114
+ "nodes": [
115
+ make_trigger_node(),
116
+ make_action_node("node-1", "TAG_CONTACT", {"tag": ""}),
117
+ ],
118
+ "edges": [make_edge("e1", "trigger-1", "node-1")],
119
+ }
120
+ errors = validate_graph(graph)
121
+ assert any(e["node_id"] == "node-1" for e in errors)
122
+
123
+
124
+ def test_validate_graph_condition_node_blocked():
125
+ """CONDITION node should be blocked from publish with a clear message."""
126
+ graph = {
127
+ "nodes": [
128
+ make_trigger_node(),
129
+ make_action_node("node-1", "CONDITION", {}),
130
+ ],
131
+ "edges": [make_edge("e1", "trigger-1", "node-1")],
132
+ }
133
+ errors = validate_graph(graph)
134
+ assert any(e["node_id"] == "node-1" and "not yet supported" in e["message"] for e in errors)
135
+
136
+
137
+ def test_validate_graph_wait_delay_blocked():
138
+ """WAIT_DELAY node should be blocked from publish."""
139
+ graph = {
140
+ "nodes": [
141
+ make_trigger_node(),
142
+ make_action_node("node-1", "WAIT_DELAY", {}),
143
+ ],
144
+ "edges": [make_edge("e1", "trigger-1", "node-1")],
145
+ }
146
+ errors = validate_graph(graph)
147
+ assert any(e["node_id"] == "node-1" and "not yet supported" in e["message"] for e in errors)
148
+
149
+
150
+ def test_validate_graph_disconnected_node():
151
+ """Action node not connected to trigger should return a reachability error."""
152
+ graph = {
153
+ "nodes": [
154
+ make_trigger_node(),
155
+ make_action_node("node-1", "AI_REPLY", {"goal": "test"}),
156
+ make_action_node("orphan", "SEND_MESSAGE", {"content": "Hello"}),
157
+ ],
158
+ "edges": [make_edge("e1", "trigger-1", "node-1")],
159
+ # "orphan" is not connected
160
+ }
161
+ errors = validate_graph(graph)
162
+ assert any(e["node_id"] == "orphan" and "not connected" in e["message"] for e in errors)
163
+
164
+
165
+ def test_validate_graph_lead_ad_submit_trigger():
166
+ """LEAD_AD_SUBMIT trigger type should be valid."""
167
+ graph = {
168
+ "nodes": [
169
+ make_trigger_node("t1", "LEAD_AD_SUBMIT", "meta"),
170
+ make_action_node("node-1", "AI_REPLY", {"goal": "Qualify lead"}),
171
+ ],
172
+ "edges": [make_edge("e1", "t1", "node-1")],
173
+ }
174
+ errors = validate_graph(graph)
175
+ assert errors == []
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # translate tests
180
+ # ---------------------------------------------------------------------------
181
+
182
+ def test_translate_produces_valid_contract():
183
+ """translate() should produce a valid runtime definition_json."""
184
+ graph = make_valid_graph()
185
+ definition = translate(graph)
186
+
187
+ assert "nodes" in definition
188
+ assert "edges" in definition
189
+ assert "start_node_id" in definition
190
+ assert definition["start_node_id"] == "trigger-1"
191
+
192
+ # Trigger node
193
+ trigger = next(n for n in definition["nodes"] if n["type"] == "TRIGGER")
194
+ assert trigger["id"] == "trigger-1"
195
+ assert trigger["config"]["trigger_type"] == "MESSAGE_INBOUND"
196
+ assert trigger["config"]["platform"] == "whatsapp"
197
+
198
+ # Action node
199
+ action = next(n for n in definition["nodes"] if n["type"] == "AI_REPLY")
200
+ assert action["config"]["goal"] == "Help user"
201
+
202
+ # Edge
203
+ assert len(definition["edges"]) == 1
204
+ edge = definition["edges"][0]
205
+ assert edge["source_node_id"] == "trigger-1"
206
+ assert edge["target_node_id"] == "node-1"
207
+
208
+
209
+ def test_translate_sets_start_node_id():
210
+ """start_node_id in output should match the trigger node id."""
211
+ graph = {
212
+ "nodes": [
213
+ make_trigger_node("my-trigger"),
214
+ make_action_node("step-1", "SEND_MESSAGE", {"content": "Hello"}),
215
+ ],
216
+ "edges": [make_edge("e1", "my-trigger", "step-1")],
217
+ }
218
+ definition = translate(graph)
219
+ assert definition["start_node_id"] == "my-trigger"
220
+
221
+
222
+ def test_translate_edge_format():
223
+ """Edges in runtime format should use source_node_id and target_node_id."""
224
+ graph = make_valid_graph()
225
+ definition = translate(graph)
226
+ edge = definition["edges"][0]
227
+ assert "source_node_id" in edge
228
+ assert "target_node_id" in edge
229
+ assert "source" not in edge # React Flow format stripped
230
+
231
+
232
+ def test_translate_multiple_nodes():
233
+ """Multiple action nodes should all be translated correctly."""
234
+ graph = {
235
+ "nodes": [
236
+ make_trigger_node(),
237
+ make_action_node("n1", "AI_REPLY", {"goal": "Greet"}),
238
+ make_action_node("n2", "ZOHO_UPSERT_LEAD", {}),
239
+ make_action_node("n3", "HUMAN_HANDOVER", {}),
240
+ ],
241
+ "edges": [
242
+ make_edge("e1", "trigger-1", "n1"),
243
+ make_edge("e2", "n1", "n2"),
244
+ make_edge("e3", "n2", "n3"),
245
+ ],
246
+ }
247
+ definition = translate(graph)
248
+ assert len(definition["nodes"]) == 4
249
+ assert len(definition["edges"]) == 3
250
+ types = {n["type"] for n in definition["nodes"]}
251
+ assert "TRIGGER" in types
252
+ assert "AI_REPLY" in types
253
+ assert "ZOHO_UPSERT_LEAD" in types
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # simulate tests
258
+ # ---------------------------------------------------------------------------
259
+
260
+ def test_simulate_returns_steps():
261
+ """simulate() should return a steps array for a valid graph."""
262
+ graph = make_valid_graph()
263
+ result = simulate(graph)
264
+ assert "steps" in result
265
+ assert len(result["steps"]) >= 1
266
+ assert result["dispatch_blocked"] is True
267
+
268
+
269
+ def test_simulate_no_dispatch_side_effects():
270
+ """simulate() should explicitly mark dispatch as blocked."""
271
+ graph = make_valid_graph()
272
+ result = simulate(graph)
273
+ assert result["dispatch_blocked"] is True
274
+ # All steps with dispatch info should have it blocked
275
+ for step in result["steps"]:
276
+ if "would_dispatch" in step:
277
+ assert step["would_dispatch"] is False
278
+
279
+
280
+ def test_simulate_empty_graph():
281
+ """simulate() with no trigger should return empty steps with message."""
282
+ graph = {"nodes": [], "edges": []}
283
+ result = simulate(graph)
284
+ assert result["steps"] == []
285
+ assert "No trigger" in result["message"]
286
+
287
+
288
+ def test_simulate_with_mock_payload():
289
+ """simulate() should include mock payload in trigger step."""
290
+ graph = make_valid_graph()
291
+ mock = {"content": "Hello!", "sender": "+1234567890"}
292
+ result = simulate(graph, mock_payload=mock)
293
+ trigger_step = next(
294
+ (s for s in result["steps"] if s["node_type"] in ("MESSAGE_INBOUND", "LEAD_AD_SUBMIT")),
295
+ None
296
+ )
297
+ if trigger_step:
298
+ assert "mock_payload" in trigger_step
299
+
300
+
301
+ def test_simulate_send_message_shows_content():
302
+ """Simulate should show SEND_MESSAGE content in would_send field."""
303
+ graph = {
304
+ "nodes": [
305
+ make_trigger_node(),
306
+ make_action_node("n1", "SEND_MESSAGE", {"content": "Welcome!"}),
307
+ ],
308
+ "edges": [make_edge("e1", "trigger-1", "n1")],
309
+ }
310
+ result = simulate(graph)
311
+ send_step = next(s for s in result["steps"] if s["node_type"] == "SEND_MESSAGE")
312
+ assert send_step["would_send"] == "Welcome!"
313
+
314
+
315
+ def test_simulate_multiple_steps_in_order():
316
+ """Simulate traversal should follow edges in order."""
317
+ graph = {
318
+ "nodes": [
319
+ make_trigger_node(),
320
+ make_action_node("n1", "AI_REPLY", {"goal": "Greet"}),
321
+ make_action_node("n2", "TAG_CONTACT", {"tag": "interested"}),
322
+ ],
323
+ "edges": [
324
+ make_edge("e1", "trigger-1", "n1"),
325
+ make_edge("e2", "n1", "n2"),
326
+ ],
327
+ }
328
+ result = simulate(graph)
329
+ node_types = [s["node_type"] for s in result["steps"]]
330
+ # Trigger comes first, then AI_REPLY, then TAG_CONTACT
331
+ assert node_types.index("AI_REPLY") < node_types.index("TAG_CONTACT")
backend/tests/test_templates.py ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Integration tests for the Template Catalog (Mission 27).
3
+
4
+ Covers:
5
+ - Admin: create/patch/list templates and versions, publish
6
+ - Workspace: list, get, clone templates
7
+ - Edge cases: duplicate slug, invalid graph, no published version
8
+ """
9
+ import pytest
10
+ from httpx import AsyncClient
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from app.core.security import get_password_hash
14
+ from app.models.models import User
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Shared graph fixture
18
+ # ---------------------------------------------------------------------------
19
+
20
+ VALID_BUILDER_GRAPH = {
21
+ "nodes": [
22
+ {
23
+ "id": "trigger-1",
24
+ "type": "triggerNode",
25
+ "position": {"x": 250, "y": 50},
26
+ "data": {"nodeType": "MESSAGE_INBOUND", "platform": "whatsapp", "config": {}},
27
+ },
28
+ {
29
+ "id": "node-1",
30
+ "type": "actionNode",
31
+ "position": {"x": 250, "y": 220},
32
+ "data": {
33
+ "nodeType": "AI_REPLY",
34
+ "config": {"goal": "Welcome and qualify the lead", "tasks": []},
35
+ },
36
+ },
37
+ ],
38
+ "edges": [{"id": "e1", "source": "trigger-1", "target": "node-1"}],
39
+ }
40
+
41
+ INVALID_BUILDER_GRAPH = {
42
+ "nodes": [
43
+ {
44
+ "id": "trigger-1",
45
+ "type": "triggerNode",
46
+ "position": {"x": 250, "y": 50},
47
+ "data": {"nodeType": "MESSAGE_INBOUND", "platform": "whatsapp", "config": {}},
48
+ },
49
+ {
50
+ "id": "node-1",
51
+ "type": "actionNode",
52
+ "position": {"x": 250, "y": 220},
53
+ "data": {"nodeType": "AI_REPLY", "config": {"goal": ""}}, # missing goal → invalid
54
+ },
55
+ ],
56
+ "edges": [{"id": "e1", "source": "trigger-1", "target": "node-1"}],
57
+ }
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Auth Helpers
61
+ # ---------------------------------------------------------------------------
62
+
63
+ async def get_admin_headers(client: AsyncClient, db_session: AsyncSession, suffix: str) -> dict:
64
+ """Create a superadmin user directly in DB and return auth headers."""
65
+ email = f"tmpl_admin_{suffix}@leadpilot.io"
66
+ admin = User(
67
+ email=email,
68
+ hashed_password=get_password_hash("Admin1234!"),
69
+ full_name=f"Template Admin {suffix}",
70
+ is_active=True,
71
+ is_superuser=True,
72
+ )
73
+ db_session.add(admin)
74
+ await db_session.flush()
75
+
76
+ r = await client.post(
77
+ "/api/v1/auth/login",
78
+ data={"username": email, "password": "Admin1234!"},
79
+ headers={"content-type": "application/x-www-form-urlencoded"},
80
+ )
81
+ token = r.json()["data"]["access_token"]
82
+ return {"Authorization": f"Bearer {token}"}
83
+
84
+
85
+ async def get_ws_headers(client: AsyncClient, suffix: str) -> dict:
86
+ """Create a regular workspace user and return auth + workspace headers."""
87
+ email = f"tmpl_ws_{suffix}@example.com"
88
+ pwd = "password123"
89
+ await client.post("/api/v1/auth/signup", json={"email": email, "password": pwd, "full_name": "WS User"})
90
+ r = await client.post("/api/v1/auth/login", data={"username": email, "password": pwd})
91
+ token = r.json()["data"]["access_token"]
92
+ ws_res = await client.get("/api/v1/workspaces", headers={"Authorization": f"Bearer {token}"})
93
+ ws_id = ws_res.json()["data"][0]["id"]
94
+ return {"Authorization": f"Bearer {token}", "X-Workspace-ID": ws_id}
95
+
96
+
97
+ async def create_published_template(
98
+ client: AsyncClient, db_session: AsyncSession, slug: str, suffix: str
99
+ ) -> tuple[str, dict]:
100
+ """
101
+ Helper: create a template + published version via admin endpoints.
102
+ Returns (template_id, admin_headers).
103
+ """
104
+ admin_headers = await get_admin_headers(client, db_session, suffix)
105
+
106
+ # Create template
107
+ res = await client.post(
108
+ "/api/v1/admin/templates",
109
+ json={
110
+ "slug": slug,
111
+ "name": f"Template {slug}",
112
+ "description": "Test template",
113
+ "category": "lead_generation",
114
+ "platforms": ["whatsapp"],
115
+ },
116
+ headers=admin_headers,
117
+ )
118
+ assert res.status_code == 200, f"Create template failed: {res.text}"
119
+ template_id = res.json()["data"]["id"]
120
+
121
+ # Create version
122
+ res = await client.post(
123
+ f"/api/v1/admin/templates/{template_id}/versions",
124
+ json={"builder_graph_json": VALID_BUILDER_GRAPH, "changelog": "Initial version"},
125
+ headers=admin_headers,
126
+ )
127
+ assert res.status_code == 200, f"Create version failed: {res.text}"
128
+
129
+ # Publish version
130
+ res = await client.post(
131
+ f"/api/v1/admin/templates/{template_id}/publish",
132
+ headers=admin_headers,
133
+ )
134
+ assert res.status_code == 200, f"Publish failed: {res.text}"
135
+
136
+ return template_id, admin_headers
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Admin — Template CRUD
141
+ # ---------------------------------------------------------------------------
142
+
143
+ @pytest.mark.asyncio
144
+ async def test_admin_create_template(async_client: AsyncClient, db_session: AsyncSession):
145
+ """Admin can create a new template. Returns id + slug."""
146
+ headers = await get_admin_headers(async_client, db_session, "create")
147
+
148
+ res = await async_client.post(
149
+ "/api/v1/admin/templates",
150
+ json={
151
+ "slug": "welcome-bot",
152
+ "name": "Welcome Bot",
153
+ "description": "Greet new leads automatically.",
154
+ "category": "lead_generation",
155
+ "platforms": ["whatsapp"],
156
+ "is_featured": True,
157
+ },
158
+ headers=headers,
159
+ )
160
+ assert res.status_code == 200
161
+ data = res.json()["data"]
162
+ assert data["slug"] == "welcome-bot"
163
+ assert "id" in data
164
+
165
+
166
+ @pytest.mark.asyncio
167
+ async def test_admin_create_template_duplicate_slug_fails(
168
+ async_client: AsyncClient, db_session: AsyncSession
169
+ ):
170
+ """Creating two templates with the same slug returns an error."""
171
+ headers = await get_admin_headers(async_client, db_session, "dup")
172
+
173
+ payload = {"slug": "dup-slug", "name": "First"}
174
+ await async_client.post("/api/v1/admin/templates", json=payload, headers=headers)
175
+
176
+ res = await async_client.post("/api/v1/admin/templates", json=payload, headers=headers)
177
+ assert res.status_code == 200
178
+ assert res.json()["success"] is False
179
+ assert "already exists" in res.json()["error"]
180
+
181
+
182
+ @pytest.mark.asyncio
183
+ async def test_admin_patch_template(async_client: AsyncClient, db_session: AsyncSession):
184
+ """Admin can patch template name and featured flag."""
185
+ headers = await get_admin_headers(async_client, db_session, "patch")
186
+
187
+ create_res = await async_client.post(
188
+ "/api/v1/admin/templates",
189
+ json={"slug": "patch-test", "name": "Original Name"},
190
+ headers=headers,
191
+ )
192
+ template_id = create_res.json()["data"]["id"]
193
+
194
+ res = await async_client.patch(
195
+ f"/api/v1/admin/templates/{template_id}",
196
+ json={"name": "Updated Name", "is_featured": True},
197
+ headers=headers,
198
+ )
199
+ assert res.status_code == 200
200
+ assert res.json()["data"]["updated"] is True
201
+
202
+
203
+ @pytest.mark.asyncio
204
+ async def test_admin_list_templates_includes_inactive(
205
+ async_client: AsyncClient, db_session: AsyncSession
206
+ ):
207
+ """Admin template list includes both active and inactive templates."""
208
+ headers = await get_admin_headers(async_client, db_session, "listall")
209
+
210
+ # Create one and deactivate
211
+ res = await async_client.post(
212
+ "/api/v1/admin/templates",
213
+ json={"slug": "inactive-one", "name": "Inactive"},
214
+ headers=headers,
215
+ )
216
+ template_id = res.json()["data"]["id"]
217
+ await async_client.patch(
218
+ f"/api/v1/admin/templates/{template_id}",
219
+ json={"is_active": False},
220
+ headers=headers,
221
+ )
222
+
223
+ list_res = await async_client.get("/api/v1/admin/templates", headers=headers)
224
+ assert list_res.status_code == 200
225
+ items = list_res.json()["data"]["items"]
226
+ # The deactivated template should be in the list
227
+ found = any(i["slug"] == "inactive-one" for i in items)
228
+ assert found, "Admin list should include inactive templates"
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # Admin — Template Versions
233
+ # ---------------------------------------------------------------------------
234
+
235
+ @pytest.mark.asyncio
236
+ async def test_admin_create_template_version(async_client: AsyncClient, db_session: AsyncSession):
237
+ """Admin can create a new draft version. Returns version_id + version_number."""
238
+ headers = await get_admin_headers(async_client, db_session, "ver_create")
239
+
240
+ create_res = await async_client.post(
241
+ "/api/v1/admin/templates",
242
+ json={"slug": "ver-create-test", "name": "Version Create Test"},
243
+ headers=headers,
244
+ )
245
+ template_id = create_res.json()["data"]["id"]
246
+
247
+ res = await async_client.post(
248
+ f"/api/v1/admin/templates/{template_id}/versions",
249
+ json={"builder_graph_json": VALID_BUILDER_GRAPH, "changelog": "v1 initial"},
250
+ headers=headers,
251
+ )
252
+ assert res.status_code == 200
253
+ data = res.json()["data"]
254
+ assert data["valid"] is True
255
+ assert data["version_number"] == 1
256
+ assert "id" in data
257
+
258
+
259
+ @pytest.mark.asyncio
260
+ async def test_admin_create_version_invalid_graph_returns_errors(
261
+ async_client: AsyncClient, db_session: AsyncSession
262
+ ):
263
+ """Creating a version with an invalid graph returns validation errors, not 500."""
264
+ headers = await get_admin_headers(async_client, db_session, "ver_invalid")
265
+
266
+ create_res = await async_client.post(
267
+ "/api/v1/admin/templates",
268
+ json={"slug": "ver-invalid-test", "name": "Invalid Version Test"},
269
+ headers=headers,
270
+ )
271
+ template_id = create_res.json()["data"]["id"]
272
+
273
+ res = await async_client.post(
274
+ f"/api/v1/admin/templates/{template_id}/versions",
275
+ json={"builder_graph_json": INVALID_BUILDER_GRAPH},
276
+ headers=headers,
277
+ )
278
+ assert res.status_code == 200
279
+ data = res.json()["data"]
280
+ assert data["valid"] is False
281
+ assert len(data["errors"]) >= 1
282
+
283
+
284
+ @pytest.mark.asyncio
285
+ async def test_admin_publish_template_version(
286
+ async_client: AsyncClient, db_session: AsyncSession
287
+ ):
288
+ """
289
+ After creating a version, publish marks it is_published=True.
290
+ Response includes version_number + published_at.
291
+ """
292
+ headers = await get_admin_headers(async_client, db_session, "pub_ver")
293
+
294
+ res = await async_client.post(
295
+ "/api/v1/admin/templates",
296
+ json={"slug": "pub-ver-test", "name": "Pub Ver Test"},
297
+ headers=headers,
298
+ )
299
+ template_id = res.json()["data"]["id"]
300
+
301
+ await async_client.post(
302
+ f"/api/v1/admin/templates/{template_id}/versions",
303
+ json={"builder_graph_json": VALID_BUILDER_GRAPH},
304
+ headers=headers,
305
+ )
306
+
307
+ pub_res = await async_client.post(
308
+ f"/api/v1/admin/templates/{template_id}/publish",
309
+ headers=headers,
310
+ )
311
+ assert pub_res.status_code == 200
312
+ data = pub_res.json()["data"]
313
+ assert data["published"] is True
314
+ assert data["version_number"] == 1
315
+ assert data["published_at"] is not None
316
+
317
+
318
+ @pytest.mark.asyncio
319
+ async def test_admin_publish_no_unpublished_version_fails(
320
+ async_client: AsyncClient, db_session: AsyncSession
321
+ ):
322
+ """Publishing when all versions are already published returns an error."""
323
+ template_id, headers = await create_published_template(
324
+ async_client, db_session, "already-pub", "alreadypub"
325
+ )
326
+
327
+ # Try to publish again — no more unpublished versions
328
+ res = await async_client.post(
329
+ f"/api/v1/admin/templates/{template_id}/publish",
330
+ headers=headers,
331
+ )
332
+ assert res.status_code == 200
333
+ assert res.json()["success"] is False
334
+
335
+
336
+ @pytest.mark.asyncio
337
+ async def test_admin_list_template_versions(
338
+ async_client: AsyncClient, db_session: AsyncSession
339
+ ):
340
+ """Admin can list all versions for a template."""
341
+ headers = await get_admin_headers(async_client, db_session, "list_ver")
342
+
343
+ res = await async_client.post(
344
+ "/api/v1/admin/templates",
345
+ json={"slug": "list-ver-test", "name": "List Ver Test"},
346
+ headers=headers,
347
+ )
348
+ template_id = res.json()["data"]["id"]
349
+
350
+ # Create two versions
351
+ await async_client.post(
352
+ f"/api/v1/admin/templates/{template_id}/versions",
353
+ json={"builder_graph_json": VALID_BUILDER_GRAPH, "changelog": "v1"},
354
+ headers=headers,
355
+ )
356
+ await async_client.post(
357
+ f"/api/v1/admin/templates/{template_id}/publish",
358
+ headers=headers,
359
+ )
360
+ await async_client.post(
361
+ f"/api/v1/admin/templates/{template_id}/versions",
362
+ json={"builder_graph_json": VALID_BUILDER_GRAPH, "changelog": "v2"},
363
+ headers=headers,
364
+ )
365
+
366
+ ver_res = await async_client.get(
367
+ f"/api/v1/admin/templates/{template_id}/versions",
368
+ headers=headers,
369
+ )
370
+ assert ver_res.status_code == 200
371
+ versions = ver_res.json()["data"]
372
+ assert len(versions) == 2
373
+ # Newest first
374
+ assert versions[0]["version_number"] == 2
375
+ assert versions[1]["version_number"] == 1
376
+ assert versions[1]["is_published"] is True
377
+
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # Workspace — Template Catalog Browsing
381
+ # ---------------------------------------------------------------------------
382
+
383
+ @pytest.mark.asyncio
384
+ async def test_workspace_list_templates(
385
+ async_client: AsyncClient, db_session: AsyncSession
386
+ ):
387
+ """Workspace users only see active templates with published versions."""
388
+ # Create and publish a template via admin
389
+ await create_published_template(async_client, db_session, "ws-list-t1", "wslist1")
390
+
391
+ # Create an inactive template
392
+ admin_headers = await get_admin_headers(async_client, db_session, "wslist_inactive")
393
+ res = await async_client.post(
394
+ "/api/v1/admin/templates",
395
+ json={"slug": "ws-inactive-t", "name": "Inactive Template"},
396
+ headers=admin_headers,
397
+ )
398
+ template_id = res.json()["data"]["id"]
399
+ await async_client.patch(
400
+ f"/api/v1/admin/templates/{template_id}",
401
+ json={"is_active": False},
402
+ headers=admin_headers,
403
+ )
404
+
405
+ ws_headers = await get_ws_headers(async_client, "wslist")
406
+ list_res = await async_client.get("/api/v1/templates", headers=ws_headers)
407
+ assert list_res.status_code == 200
408
+ templates = list_res.json()["data"]
409
+
410
+ # All returned templates must be active
411
+ for t in templates:
412
+ assert t["slug"] != "ws-inactive-t", "Inactive template should not appear"
413
+
414
+
415
+ @pytest.mark.asyncio
416
+ async def test_workspace_get_template_detail(
417
+ async_client: AsyncClient, db_session: AsyncSession
418
+ ):
419
+ """GET /templates/{slug} returns full detail including latest_version."""
420
+ await create_published_template(async_client, db_session, "ws-detail-t", "wsdetail")
421
+
422
+ ws_headers = await get_ws_headers(async_client, "wsdetail_user")
423
+ res = await async_client.get("/api/v1/templates/ws-detail-t", headers=ws_headers)
424
+ assert res.status_code == 200
425
+ data = res.json()["data"]
426
+ assert data["slug"] == "ws-detail-t"
427
+ assert data["latest_version"] is not None
428
+ assert data["latest_version"]["builder_graph_json"] is not None
429
+ assert data["latest_version"]["version_number"] == 1
430
+
431
+
432
+ @pytest.mark.asyncio
433
+ async def test_workspace_get_template_not_found(async_client: AsyncClient, db_session: AsyncSession):
434
+ """GET /templates/{slug} for non-existent slug returns error."""
435
+ ws_headers = await get_ws_headers(async_client, "wsnotfound")
436
+ res = await async_client.get("/api/v1/templates/does-not-exist", headers=ws_headers)
437
+ assert res.status_code == 200
438
+ assert res.json()["success"] is False
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # Workspace — Clone Template
443
+ # ---------------------------------------------------------------------------
444
+
445
+ @pytest.mark.asyncio
446
+ async def test_workspace_clone_template(
447
+ async_client: AsyncClient, db_session: AsyncSession
448
+ ):
449
+ """
450
+ POST /templates/{slug}/clone:
451
+ - Creates a Flow (DRAFT) in the workspace
452
+ - Creates a FlowDraft with builder_graph_json from the template version
453
+ - Returns flow_id + redirect_path
454
+ """
455
+ await create_published_template(async_client, db_session, "ws-clone-t", "wsclone")
456
+
457
+ ws_headers = await get_ws_headers(async_client, "wsclone_user")
458
+ res = await async_client.post(
459
+ "/api/v1/templates/ws-clone-t/clone",
460
+ json={"name": "My Cloned Flow"},
461
+ headers=ws_headers,
462
+ )
463
+ assert res.status_code == 200
464
+ data = res.json()["data"]
465
+ assert "flow_id" in data
466
+ assert data["redirect_path"] == f"/automations/{data['flow_id']}"
467
+ assert data["template_slug"] == "ws-clone-t"
468
+
469
+ # Verify draft was created with the template graph
470
+ flow_id = data["flow_id"]
471
+ draft_res = await async_client.get(
472
+ f"/api/v1/automations/{flow_id}/draft",
473
+ headers=ws_headers,
474
+ )
475
+ assert draft_res.status_code == 200
476
+ draft = draft_res.json()["data"]
477
+ assert draft["builder_graph_json"] is not None
478
+ assert len(draft["builder_graph_json"]["nodes"]) == 2 # trigger + AI_REPLY
479
+
480
+
481
+ @pytest.mark.asyncio
482
+ async def test_workspace_clone_template_default_name(
483
+ async_client: AsyncClient, db_session: AsyncSession
484
+ ):
485
+ """Clone without a name defaults to the template name."""
486
+ await create_published_template(async_client, db_session, "ws-noname-t", "wsnoname")
487
+
488
+ ws_headers = await get_ws_headers(async_client, "wsnoname_user")
489
+ res = await async_client.post(
490
+ "/api/v1/templates/ws-noname-t/clone",
491
+ json={}, # no name provided
492
+ headers=ws_headers,
493
+ )
494
+ assert res.status_code == 200
495
+ data = res.json()["data"]
496
+ assert data["flow_name"] == "Template ws-noname-t"
497
+
498
+
499
+ @pytest.mark.asyncio
500
+ async def test_workspace_clone_no_published_version_fails(
501
+ async_client: AsyncClient, db_session: AsyncSession
502
+ ):
503
+ """Clone a template that exists but has no published version returns an error."""
504
+ admin_headers = await get_admin_headers(async_client, db_session, "nopubver")
505
+
506
+ # Create template WITHOUT publishing a version
507
+ await async_client.post(
508
+ "/api/v1/admin/templates",
509
+ json={"slug": "no-pub-version", "name": "No Published Version"},
510
+ headers=admin_headers,
511
+ )
512
+
513
+ ws_headers = await get_ws_headers(async_client, "nopub_user")
514
+ res = await async_client.post(
515
+ "/api/v1/templates/no-pub-version/clone",
516
+ json={},
517
+ headers=ws_headers,
518
+ )
519
+ assert res.status_code == 200
520
+ assert res.json()["success"] is False
521
+ assert "no published version" in res.json()["error"].lower()
522
+
523
+
524
+ @pytest.mark.asyncio
525
+ async def test_workspace_clone_nonexistent_template_fails(
526
+ async_client: AsyncClient, db_session: AsyncSession
527
+ ):
528
+ """Clone a template that doesn't exist returns an error."""
529
+ ws_headers = await get_ws_headers(async_client, "clone_missing")
530
+ res = await async_client.post(
531
+ "/api/v1/templates/absolutely-does-not-exist/clone",
532
+ json={},
533
+ headers=ws_headers,
534
+ )
535
+ assert res.status_code == 200
536
+ assert res.json()["success"] is False
frontend/package-lock.json CHANGED
@@ -8,6 +8,7 @@
8
  "name": "frontend",
9
  "version": "0.1.0",
10
  "dependencies": {
 
11
  "clsx": "^2.1.1",
12
  "date-fns": "^4.1.0",
13
  "lucide-react": "^0.574.0",
@@ -1530,6 +1531,55 @@
1530
  "tslib": "^2.4.0"
1531
  }
1532
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1533
  "node_modules/@types/estree": {
1534
  "version": "1.0.8",
1535
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1565,7 +1615,7 @@
1565
  "version": "19.2.14",
1566
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
1567
  "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
1568
- "dev": true,
1569
  "license": "MIT",
1570
  "peer": true,
1571
  "dependencies": {
@@ -2134,6 +2184,38 @@
2134
  "win32"
2135
  ]
2136
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2137
  "node_modules/acorn": {
2138
  "version": "8.15.0",
2139
  "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2593,6 +2675,12 @@
2593
  "url": "https://github.com/chalk/chalk?sponsor=1"
2594
  }
2595
  },
 
 
 
 
 
 
2596
  "node_modules/client-only": {
2597
  "version": "0.0.1",
2598
  "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -2661,9 +2749,115 @@
2661
  "version": "3.2.3",
2662
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
2663
  "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
2664
- "dev": true,
2665
  "license": "MIT"
2666
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2667
  "node_modules/damerau-levenshtein": {
2668
  "version": "1.0.8",
2669
  "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -6478,6 +6672,15 @@
6478
  "punycode": "^2.1.0"
6479
  }
6480
  },
 
 
 
 
 
 
 
 
 
6481
  "node_modules/which": {
6482
  "version": "2.0.2",
6483
  "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6636,6 +6839,34 @@
6636
  "peerDependencies": {
6637
  "zod": "^3.25.0 || ^4.0.0"
6638
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6639
  }
6640
  }
6641
  }
 
8
  "name": "frontend",
9
  "version": "0.1.0",
10
  "dependencies": {
11
+ "@xyflow/react": "^12.10.1",
12
  "clsx": "^2.1.1",
13
  "date-fns": "^4.1.0",
14
  "lucide-react": "^0.574.0",
 
1531
  "tslib": "^2.4.0"
1532
  }
1533
  },
1534
+ "node_modules/@types/d3-color": {
1535
+ "version": "3.1.3",
1536
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
1537
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
1538
+ "license": "MIT"
1539
+ },
1540
+ "node_modules/@types/d3-drag": {
1541
+ "version": "3.0.7",
1542
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
1543
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
1544
+ "license": "MIT",
1545
+ "dependencies": {
1546
+ "@types/d3-selection": "*"
1547
+ }
1548
+ },
1549
+ "node_modules/@types/d3-interpolate": {
1550
+ "version": "3.0.4",
1551
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
1552
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
1553
+ "license": "MIT",
1554
+ "dependencies": {
1555
+ "@types/d3-color": "*"
1556
+ }
1557
+ },
1558
+ "node_modules/@types/d3-selection": {
1559
+ "version": "3.0.11",
1560
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
1561
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
1562
+ "license": "MIT"
1563
+ },
1564
+ "node_modules/@types/d3-transition": {
1565
+ "version": "3.0.9",
1566
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
1567
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
1568
+ "license": "MIT",
1569
+ "dependencies": {
1570
+ "@types/d3-selection": "*"
1571
+ }
1572
+ },
1573
+ "node_modules/@types/d3-zoom": {
1574
+ "version": "3.0.8",
1575
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
1576
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
1577
+ "license": "MIT",
1578
+ "dependencies": {
1579
+ "@types/d3-interpolate": "*",
1580
+ "@types/d3-selection": "*"
1581
+ }
1582
+ },
1583
  "node_modules/@types/estree": {
1584
  "version": "1.0.8",
1585
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
 
1615
  "version": "19.2.14",
1616
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
1617
  "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
1618
+ "devOptional": true,
1619
  "license": "MIT",
1620
  "peer": true,
1621
  "dependencies": {
 
2184
  "win32"
2185
  ]
2186
  },
2187
+ "node_modules/@xyflow/react": {
2188
+ "version": "12.10.1",
2189
+ "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
2190
+ "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==",
2191
+ "license": "MIT",
2192
+ "dependencies": {
2193
+ "@xyflow/system": "0.0.75",
2194
+ "classcat": "^5.0.3",
2195
+ "zustand": "^4.4.0"
2196
+ },
2197
+ "peerDependencies": {
2198
+ "react": ">=17",
2199
+ "react-dom": ">=17"
2200
+ }
2201
+ },
2202
+ "node_modules/@xyflow/system": {
2203
+ "version": "0.0.75",
2204
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz",
2205
+ "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==",
2206
+ "license": "MIT",
2207
+ "dependencies": {
2208
+ "@types/d3-drag": "^3.0.7",
2209
+ "@types/d3-interpolate": "^3.0.4",
2210
+ "@types/d3-selection": "^3.0.10",
2211
+ "@types/d3-transition": "^3.0.8",
2212
+ "@types/d3-zoom": "^3.0.8",
2213
+ "d3-drag": "^3.0.0",
2214
+ "d3-interpolate": "^3.0.1",
2215
+ "d3-selection": "^3.0.0",
2216
+ "d3-zoom": "^3.0.0"
2217
+ }
2218
+ },
2219
  "node_modules/acorn": {
2220
  "version": "8.15.0",
2221
  "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
 
2675
  "url": "https://github.com/chalk/chalk?sponsor=1"
2676
  }
2677
  },
2678
+ "node_modules/classcat": {
2679
+ "version": "5.0.5",
2680
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
2681
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
2682
+ "license": "MIT"
2683
+ },
2684
  "node_modules/client-only": {
2685
  "version": "0.0.1",
2686
  "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
 
2749
  "version": "3.2.3",
2750
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
2751
  "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
2752
+ "devOptional": true,
2753
  "license": "MIT"
2754
  },
2755
+ "node_modules/d3-color": {
2756
+ "version": "3.1.0",
2757
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
2758
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
2759
+ "license": "ISC",
2760
+ "engines": {
2761
+ "node": ">=12"
2762
+ }
2763
+ },
2764
+ "node_modules/d3-dispatch": {
2765
+ "version": "3.0.1",
2766
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
2767
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
2768
+ "license": "ISC",
2769
+ "engines": {
2770
+ "node": ">=12"
2771
+ }
2772
+ },
2773
+ "node_modules/d3-drag": {
2774
+ "version": "3.0.0",
2775
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
2776
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
2777
+ "license": "ISC",
2778
+ "dependencies": {
2779
+ "d3-dispatch": "1 - 3",
2780
+ "d3-selection": "3"
2781
+ },
2782
+ "engines": {
2783
+ "node": ">=12"
2784
+ }
2785
+ },
2786
+ "node_modules/d3-ease": {
2787
+ "version": "3.0.1",
2788
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
2789
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
2790
+ "license": "BSD-3-Clause",
2791
+ "engines": {
2792
+ "node": ">=12"
2793
+ }
2794
+ },
2795
+ "node_modules/d3-interpolate": {
2796
+ "version": "3.0.1",
2797
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
2798
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
2799
+ "license": "ISC",
2800
+ "dependencies": {
2801
+ "d3-color": "1 - 3"
2802
+ },
2803
+ "engines": {
2804
+ "node": ">=12"
2805
+ }
2806
+ },
2807
+ "node_modules/d3-selection": {
2808
+ "version": "3.0.0",
2809
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
2810
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
2811
+ "license": "ISC",
2812
+ "peer": true,
2813
+ "engines": {
2814
+ "node": ">=12"
2815
+ }
2816
+ },
2817
+ "node_modules/d3-timer": {
2818
+ "version": "3.0.1",
2819
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
2820
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
2821
+ "license": "ISC",
2822
+ "engines": {
2823
+ "node": ">=12"
2824
+ }
2825
+ },
2826
+ "node_modules/d3-transition": {
2827
+ "version": "3.0.1",
2828
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
2829
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
2830
+ "license": "ISC",
2831
+ "dependencies": {
2832
+ "d3-color": "1 - 3",
2833
+ "d3-dispatch": "1 - 3",
2834
+ "d3-ease": "1 - 3",
2835
+ "d3-interpolate": "1 - 3",
2836
+ "d3-timer": "1 - 3"
2837
+ },
2838
+ "engines": {
2839
+ "node": ">=12"
2840
+ },
2841
+ "peerDependencies": {
2842
+ "d3-selection": "2 - 3"
2843
+ }
2844
+ },
2845
+ "node_modules/d3-zoom": {
2846
+ "version": "3.0.0",
2847
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
2848
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
2849
+ "license": "ISC",
2850
+ "dependencies": {
2851
+ "d3-dispatch": "1 - 3",
2852
+ "d3-drag": "2 - 3",
2853
+ "d3-interpolate": "1 - 3",
2854
+ "d3-selection": "2 - 3",
2855
+ "d3-transition": "2 - 3"
2856
+ },
2857
+ "engines": {
2858
+ "node": ">=12"
2859
+ }
2860
+ },
2861
  "node_modules/damerau-levenshtein": {
2862
  "version": "1.0.8",
2863
  "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
 
6672
  "punycode": "^2.1.0"
6673
  }
6674
  },
6675
+ "node_modules/use-sync-external-store": {
6676
+ "version": "1.6.0",
6677
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
6678
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
6679
+ "license": "MIT",
6680
+ "peerDependencies": {
6681
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
6682
+ }
6683
+ },
6684
  "node_modules/which": {
6685
  "version": "2.0.2",
6686
  "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
 
6839
  "peerDependencies": {
6840
  "zod": "^3.25.0 || ^4.0.0"
6841
  }
6842
+ },
6843
+ "node_modules/zustand": {
6844
+ "version": "4.5.7",
6845
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
6846
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
6847
+ "license": "MIT",
6848
+ "dependencies": {
6849
+ "use-sync-external-store": "^1.2.2"
6850
+ },
6851
+ "engines": {
6852
+ "node": ">=12.7.0"
6853
+ },
6854
+ "peerDependencies": {
6855
+ "@types/react": ">=16.8",
6856
+ "immer": ">=9.0.6",
6857
+ "react": ">=16.8"
6858
+ },
6859
+ "peerDependenciesMeta": {
6860
+ "@types/react": {
6861
+ "optional": true
6862
+ },
6863
+ "immer": {
6864
+ "optional": true
6865
+ },
6866
+ "react": {
6867
+ "optional": true
6868
+ }
6869
+ }
6870
  }
6871
  }
6872
  }
frontend/package.json CHANGED
@@ -9,6 +9,7 @@
9
  "lint": "eslint"
10
  },
11
  "dependencies": {
 
12
  "clsx": "^2.1.1",
13
  "date-fns": "^4.1.0",
14
  "lucide-react": "^0.574.0",
 
9
  "lint": "eslint"
10
  },
11
  "dependencies": {
12
+ "@xyflow/react": "^12.10.1",
13
  "clsx": "^2.1.1",
14
  "date-fns": "^4.1.0",
15
  "lucide-react": "^0.574.0",
frontend/src/app/(admin)/admin/templates/[id]/page.tsx ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useParams } from "next/navigation";
5
+ import Link from "next/link";
6
+ import {
7
+ ArrowLeft,
8
+ Loader2,
9
+ Plus,
10
+ CheckCircle2,
11
+ AlertTriangle,
12
+ Rocket,
13
+ Star,
14
+ } from "lucide-react";
15
+ import { cn } from "@/lib/utils";
16
+ import {
17
+ patchAdminTemplate,
18
+ createTemplateVersion,
19
+ publishTemplateVersion,
20
+ getTemplateVersions,
21
+ type AdminTemplateItem,
22
+ type AdminTemplateVersionItem,
23
+ } from "@/lib/admin-api";
24
+ import { getAdminTemplates } from "@/lib/admin-api";
25
+
26
+ function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
27
+ return (
28
+ <div className="rounded-xl border border-border bg-card p-5 space-y-4 shadow-sm">
29
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h2>
30
+ {children}
31
+ </div>
32
+ );
33
+ }
34
+
35
+ export default function AdminTemplateDetailPage() {
36
+ const params = useParams();
37
+ const templateId = params.id as string;
38
+
39
+ const [template, setTemplate] = useState<AdminTemplateItem | null>(null);
40
+ const [versions, setVersions] = useState<AdminTemplateVersionItem[]>([]);
41
+ const [loading, setLoading] = useState(true);
42
+ const [saving, setSaving] = useState(false);
43
+ const [publishing, setPublishing] = useState(false);
44
+ const [creatingVersion, setCreatingVersion] = useState(false);
45
+ const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>(null);
46
+
47
+ // Editable fields
48
+ const [name, setName] = useState("");
49
+ const [description, setDescription] = useState("");
50
+ const [isFeatured, setIsFeatured] = useState(false);
51
+ const [isActive, setIsActive] = useState(true);
52
+
53
+ // Version creation
54
+ const [graphJson, setGraphJson] = useState("");
55
+ const [changelog, setChangelog] = useState("");
56
+ const [versionErrors, setVersionErrors] = useState<any[]>([]);
57
+
58
+ const loadTemplate = async () => {
59
+ const res = await getAdminTemplates();
60
+ if (res.success && res.data) {
61
+ const found = res.data.items.find((t) => t.id === templateId);
62
+ if (found) {
63
+ setTemplate(found);
64
+ setName(found.name);
65
+ setDescription(found.description ?? "");
66
+ setIsFeatured(found.is_featured);
67
+ setIsActive(found.is_active);
68
+ }
69
+ }
70
+ };
71
+
72
+ const loadVersions = async () => {
73
+ const res = await getTemplateVersions(templateId);
74
+ if (res.success && res.data) setVersions(res.data);
75
+ };
76
+
77
+ useEffect(() => {
78
+ Promise.all([loadTemplate(), loadVersions()]).then(() => setLoading(false));
79
+ }, [templateId]);
80
+
81
+ const showFeedback = (type: "success" | "error", message: string) => {
82
+ setFeedback({ type, message });
83
+ setTimeout(() => setFeedback(null), 4000);
84
+ };
85
+
86
+ const handleSave = async () => {
87
+ setSaving(true);
88
+ const res = await patchAdminTemplate(templateId, {
89
+ name,
90
+ description,
91
+ is_featured: isFeatured,
92
+ is_active: isActive,
93
+ });
94
+ if (res.success) {
95
+ showFeedback("success", "Template updated.");
96
+ loadTemplate();
97
+ } else {
98
+ showFeedback("error", res.error ?? "Failed to save.");
99
+ }
100
+ setSaving(false);
101
+ };
102
+
103
+ const handleCreateVersion = async () => {
104
+ setCreatingVersion(true);
105
+ setVersionErrors([]);
106
+ let parsed: Record<string, any>;
107
+ try {
108
+ parsed = JSON.parse(graphJson);
109
+ } catch {
110
+ showFeedback("error", "Invalid JSON in builder graph.");
111
+ setCreatingVersion(false);
112
+ return;
113
+ }
114
+ const res = await createTemplateVersion(templateId, {
115
+ builder_graph_json: parsed,
116
+ changelog,
117
+ });
118
+ if (res.success && res.data) {
119
+ if (res.data.valid) {
120
+ showFeedback("success", `Version ${res.data.version_number} created.`);
121
+ setGraphJson("");
122
+ setChangelog("");
123
+ loadVersions();
124
+ } else {
125
+ setVersionErrors(res.data.errors ?? []);
126
+ showFeedback("error", "Graph has validation errors — version not saved.");
127
+ }
128
+ } else {
129
+ showFeedback("error", res.error ?? "Failed to create version.");
130
+ }
131
+ setCreatingVersion(false);
132
+ };
133
+
134
+ const handlePublish = async () => {
135
+ setPublishing(true);
136
+ const res = await publishTemplateVersion(templateId);
137
+ if (res.success && res.data) {
138
+ showFeedback("success", `Version ${res.data.version_number} published.`);
139
+ loadVersions();
140
+ } else {
141
+ showFeedback("error", res.error ?? "Nothing to publish (all versions already published).");
142
+ }
143
+ setPublishing(false);
144
+ };
145
+
146
+ if (loading) {
147
+ return (
148
+ <div className="flex items-center justify-center h-64">
149
+ <Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
150
+ </div>
151
+ );
152
+ }
153
+
154
+ if (!template) {
155
+ return (
156
+ <div className="p-8">
157
+ <p className="text-muted-foreground">Template not found.</p>
158
+ </div>
159
+ );
160
+ }
161
+
162
+ return (
163
+ <div className="p-8 max-w-5xl mx-auto space-y-8">
164
+ {/* Back */}
165
+ <Link
166
+ href="/admin/templates"
167
+ className="flex items-center gap-2 text-muted-foreground hover:text-foreground text-sm w-fit"
168
+ >
169
+ <ArrowLeft className="w-4 h-4" />
170
+ Back to Templates
171
+ </Link>
172
+
173
+ {/* Feedback banner */}
174
+ {feedback && (
175
+ <div
176
+ className={cn(
177
+ "flex items-center gap-2 p-3 rounded-xl border text-sm",
178
+ feedback.type === "success"
179
+ ? "bg-teal-50 dark:bg-teal-950/30 border-teal-200 dark:border-teal-800 text-teal-700 dark:text-teal-300"
180
+ : "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300"
181
+ )}
182
+ >
183
+ {feedback.type === "success" ? (
184
+ <CheckCircle2 className="w-4 h-4 shrink-0" />
185
+ ) : (
186
+ <AlertTriangle className="w-4 h-4 shrink-0" />
187
+ )}
188
+ {feedback.message}
189
+ </div>
190
+ )}
191
+
192
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
193
+ {/* Left: Metadata */}
194
+ <div className="lg:col-span-1 space-y-4">
195
+ <SectionCard title="Template Info">
196
+ <div className="space-y-1">
197
+ <label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
198
+ Slug
199
+ </label>
200
+ <p className="text-sm font-mono text-foreground">{template.slug}</p>
201
+ </div>
202
+
203
+ {[
204
+ { label: "Name", value: name, set: setName },
205
+ ].map(({ label, value, set }) => (
206
+ <div key={label} className="space-y-1">
207
+ <label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
208
+ {label}
209
+ </label>
210
+ <input
211
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
212
+ value={value}
213
+ onChange={(e) => set(e.target.value)}
214
+ />
215
+ </div>
216
+ ))}
217
+
218
+ <div className="space-y-1">
219
+ <label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
220
+ Description
221
+ </label>
222
+ <textarea
223
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-teal-500 min-h-[70px]"
224
+ value={description}
225
+ onChange={(e) => setDescription(e.target.value)}
226
+ />
227
+ </div>
228
+
229
+ <div className="flex items-center gap-4 pt-1">
230
+ <label className="flex items-center gap-2 text-sm cursor-pointer">
231
+ <input
232
+ type="checkbox"
233
+ checked={isFeatured}
234
+ onChange={(e) => setIsFeatured(e.target.checked)}
235
+ className="accent-teal-600"
236
+ />
237
+ <Star className="w-3.5 h-3.5 text-amber-500 fill-current" />
238
+ Featured
239
+ </label>
240
+ <label className="flex items-center gap-2 text-sm cursor-pointer">
241
+ <input
242
+ type="checkbox"
243
+ checked={isActive}
244
+ onChange={(e) => setIsActive(e.target.checked)}
245
+ className="accent-teal-600"
246
+ />
247
+ Active
248
+ </label>
249
+ </div>
250
+
251
+ <button
252
+ onClick={handleSave}
253
+ disabled={saving}
254
+ className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 transition-colors"
255
+ >
256
+ {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
257
+ Save Changes
258
+ </button>
259
+ </SectionCard>
260
+ </div>
261
+
262
+ {/* Right: Versions */}
263
+ <div className="lg:col-span-2 space-y-4">
264
+ <SectionCard title="Versions">
265
+ {/* Publish latest */}
266
+ <button
267
+ onClick={handlePublish}
268
+ disabled={publishing}
269
+ className="flex items-center gap-2 px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 transition-colors"
270
+ >
271
+ {publishing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Rocket className="w-4 h-4" />}
272
+ Publish Latest Draft
273
+ </button>
274
+
275
+ {versions.length === 0 ? (
276
+ <p className="text-sm text-muted-foreground">No versions yet.</p>
277
+ ) : (
278
+ <div className="rounded-lg border border-border divide-y divide-border overflow-hidden">
279
+ {versions.map((v) => (
280
+ <div key={v.id} className="flex items-center justify-between px-4 py-3">
281
+ <div>
282
+ <p className="text-sm font-medium">v{v.version_number}</p>
283
+ {v.changelog && (
284
+ <p className="text-xs text-muted-foreground">{v.changelog}</p>
285
+ )}
286
+ </div>
287
+ <div className="flex items-center gap-2">
288
+ {v.is_published ? (
289
+ <span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-300 uppercase tracking-wide">
290
+ Published
291
+ </span>
292
+ ) : (
293
+ <span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-muted text-muted-foreground uppercase tracking-wide">
294
+ Draft
295
+ </span>
296
+ )}
297
+ <span className="text-xs text-muted-foreground">
298
+ {new Date(v.created_at).toLocaleDateString()}
299
+ </span>
300
+ </div>
301
+ </div>
302
+ ))}
303
+ </div>
304
+ )}
305
+ </SectionCard>
306
+
307
+ <SectionCard title="Create New Version">
308
+ <div className="space-y-3">
309
+ <div className="space-y-1">
310
+ <label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
311
+ Builder Graph JSON
312
+ </label>
313
+ <textarea
314
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-xs font-mono resize-y focus:outline-none focus:ring-2 focus:ring-teal-500 min-h-[200px]"
315
+ placeholder='{"nodes": [...], "edges": [...]}'
316
+ value={graphJson}
317
+ onChange={(e) => setGraphJson(e.target.value)}
318
+ />
319
+ </div>
320
+
321
+ {versionErrors.length > 0 && (
322
+ <div className="space-y-1 p-3 rounded-lg border border-red-200 bg-red-50 dark:bg-red-950/20 text-xs text-red-700 dark:text-red-300">
323
+ <p className="font-semibold">Validation errors:</p>
324
+ {versionErrors.map((e: any, i: number) => (
325
+ <p key={i}>{e.node_id}: {e.message}</p>
326
+ ))}
327
+ </div>
328
+ )}
329
+
330
+ <div className="space-y-1">
331
+ <label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
332
+ Changelog (optional)
333
+ </label>
334
+ <input
335
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
336
+ placeholder="What changed in this version..."
337
+ value={changelog}
338
+ onChange={(e) => setChangelog(e.target.value)}
339
+ />
340
+ </div>
341
+
342
+ <button
343
+ onClick={handleCreateVersion}
344
+ disabled={creatingVersion || !graphJson.trim()}
345
+ className="flex items-center gap-2 px-4 py-2 rounded-lg border border-border hover:border-teal-400 text-sm font-medium text-foreground disabled:opacity-50 transition-colors"
346
+ >
347
+ {creatingVersion ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
348
+ Create Draft Version
349
+ </button>
350
+ </div>
351
+ </SectionCard>
352
+ </div>
353
+ </div>
354
+ </div>
355
+ );
356
+ }
frontend/src/app/(admin)/admin/templates/page.tsx ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import Link from "next/link";
5
+ import { Plus, Loader2, Star, CheckCircle2, XCircle, LayoutTemplate } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+ import {
8
+ getAdminTemplates,
9
+ createAdminTemplate,
10
+ type AdminTemplateItem,
11
+ } from "@/lib/admin-api";
12
+
13
+ function StatusBadge({ active }: { active: boolean }) {
14
+ return (
15
+ <span
16
+ className={cn(
17
+ "inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
18
+ active
19
+ ? "bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-300"
20
+ : "bg-muted text-muted-foreground"
21
+ )}
22
+ >
23
+ {active ? <CheckCircle2 className="w-3 h-3" /> : <XCircle className="w-3 h-3" />}
24
+ {active ? "Active" : "Inactive"}
25
+ </span>
26
+ );
27
+ }
28
+
29
+ export default function AdminTemplatesPage() {
30
+ const [templates, setTemplates] = useState<AdminTemplateItem[]>([]);
31
+ const [loading, setLoading] = useState(true);
32
+ const [showCreate, setShowCreate] = useState(false);
33
+ const [creating, setCreating] = useState(false);
34
+
35
+ // Create form state
36
+ const [slug, setSlug] = useState("");
37
+ const [name, setName] = useState("");
38
+ const [description, setDescription] = useState("");
39
+ const [category, setCategory] = useState("general");
40
+ const [platforms, setPlatforms] = useState("");
41
+ const [createError, setCreateError] = useState("");
42
+
43
+ const load = () => {
44
+ setLoading(true);
45
+ getAdminTemplates().then((res) => {
46
+ if (res.success && res.data) setTemplates(res.data.items);
47
+ setLoading(false);
48
+ });
49
+ };
50
+
51
+ useEffect(() => { load(); }, []);
52
+
53
+ const handleCreate = async () => {
54
+ setCreating(true);
55
+ setCreateError("");
56
+ const res = await createAdminTemplate({
57
+ slug,
58
+ name,
59
+ description,
60
+ category,
61
+ platforms: platforms.split(",").map((p) => p.trim()).filter(Boolean),
62
+ });
63
+ if (res.success) {
64
+ setShowCreate(false);
65
+ setSlug(""); setName(""); setDescription(""); setCategory("general"); setPlatforms("");
66
+ load();
67
+ } else {
68
+ setCreateError(res.error ?? "Failed to create template.");
69
+ }
70
+ setCreating(false);
71
+ };
72
+
73
+ return (
74
+ <div className="p-8 max-w-6xl mx-auto space-y-8">
75
+ {/* Header */}
76
+ <div className="flex items-center justify-between">
77
+ <div className="flex items-center gap-3">
78
+ <LayoutTemplate className="w-6 h-6 text-teal-600" />
79
+ <div>
80
+ <h1 className="text-2xl font-bold">Templates</h1>
81
+ <p className="text-sm text-muted-foreground">Manage the automation template catalog.</p>
82
+ </div>
83
+ </div>
84
+ <button
85
+ onClick={() => setShowCreate(true)}
86
+ className="flex items-center gap-2 bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg font-medium transition-colors shadow-sm"
87
+ >
88
+ <Plus className="w-4 h-4" />
89
+ New Template
90
+ </button>
91
+ </div>
92
+
93
+ {/* Create modal */}
94
+ {showCreate && (
95
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
96
+ <div className="bg-background rounded-2xl border border-border shadow-xl w-full max-w-md p-6 space-y-4">
97
+ <h2 className="text-lg font-bold">Create Template</h2>
98
+
99
+ {[
100
+ { label: "Slug", value: slug, set: setSlug, placeholder: "welcome-bot", required: true },
101
+ { label: "Name", value: name, set: setName, placeholder: "Welcome Bot", required: true },
102
+ ].map(({ label, value, set, placeholder, required }) => (
103
+ <div key={label} className="space-y-1">
104
+ <label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
105
+ {label}{required && <span className="text-red-500 ml-0.5">*</span>}
106
+ </label>
107
+ <input
108
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
109
+ placeholder={placeholder}
110
+ value={value}
111
+ onChange={(e) => set(e.target.value)}
112
+ />
113
+ </div>
114
+ ))}
115
+
116
+ <div className="space-y-1">
117
+ <label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
118
+ Description
119
+ </label>
120
+ <textarea
121
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-teal-500 min-h-[60px]"
122
+ placeholder="Describe what this template does..."
123
+ value={description}
124
+ onChange={(e) => setDescription(e.target.value)}
125
+ />
126
+ </div>
127
+
128
+ <div className="grid grid-cols-2 gap-3">
129
+ <div className="space-y-1">
130
+ <label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
131
+ Category
132
+ </label>
133
+ <select
134
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
135
+ value={category}
136
+ onChange={(e) => setCategory(e.target.value)}
137
+ >
138
+ {["general", "lead_generation", "customer_support", "sales", "onboarding"].map((c) => (
139
+ <option key={c} value={c}>{c}</option>
140
+ ))}
141
+ </select>
142
+ </div>
143
+ <div className="space-y-1">
144
+ <label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
145
+ Platforms (comma-sep)
146
+ </label>
147
+ <input
148
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
149
+ placeholder="whatsapp, meta"
150
+ value={platforms}
151
+ onChange={(e) => setPlatforms(e.target.value)}
152
+ />
153
+ </div>
154
+ </div>
155
+
156
+ {createError && (
157
+ <p className="text-xs text-red-600">{createError}</p>
158
+ )}
159
+
160
+ <div className="flex gap-2 pt-2">
161
+ <button
162
+ onClick={() => { setShowCreate(false); setCreateError(""); }}
163
+ className="flex-1 py-2 rounded-lg border border-border text-sm font-medium hover:bg-muted transition-colors"
164
+ >
165
+ Cancel
166
+ </button>
167
+ <button
168
+ onClick={handleCreate}
169
+ disabled={creating || !slug || !name}
170
+ className="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 transition-colors"
171
+ >
172
+ {creating ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
173
+ Create
174
+ </button>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ )}
179
+
180
+ {/* Templates table */}
181
+ {loading ? (
182
+ <div className="flex items-center justify-center py-20">
183
+ <Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
184
+ </div>
185
+ ) : templates.length === 0 ? (
186
+ <div className="text-center py-20 border-2 border-dashed border-border rounded-2xl text-muted-foreground">
187
+ No templates yet. Create one to get started.
188
+ </div>
189
+ ) : (
190
+ <div className="rounded-xl border border-border overflow-hidden">
191
+ <table className="w-full text-sm">
192
+ <thead className="bg-muted/50 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
193
+ <tr>
194
+ <th className="px-4 py-3 text-left">Name</th>
195
+ <th className="px-4 py-3 text-left">Slug</th>
196
+ <th className="px-4 py-3 text-left">Category</th>
197
+ <th className="px-4 py-3 text-left">Status</th>
198
+ <th className="px-4 py-3 text-left">Featured</th>
199
+ <th className="px-4 py-3 text-left">Actions</th>
200
+ </tr>
201
+ </thead>
202
+ <tbody className="divide-y divide-border">
203
+ {templates.map((t) => (
204
+ <tr key={t.id} className="hover:bg-muted/30 transition-colors">
205
+ <td className="px-4 py-3 font-medium">{t.name}</td>
206
+ <td className="px-4 py-3 font-mono text-xs text-muted-foreground">{t.slug}</td>
207
+ <td className="px-4 py-3 text-muted-foreground">{t.category}</td>
208
+ <td className="px-4 py-3"><StatusBadge active={t.is_active} /></td>
209
+ <td className="px-4 py-3">
210
+ {t.is_featured && <Star className="w-4 h-4 text-amber-500 fill-current" />}
211
+ </td>
212
+ <td className="px-4 py-3">
213
+ <Link
214
+ href={`/admin/templates/${t.id}`}
215
+ className="text-teal-600 hover:text-teal-700 font-medium"
216
+ >
217
+ Manage →
218
+ </Link>
219
+ </td>
220
+ </tr>
221
+ ))}
222
+ </tbody>
223
+ </table>
224
+ </div>
225
+ )}
226
+ </div>
227
+ );
228
+ }
frontend/src/app/(dashboard)/automations/[id]/NodeConfigPanel.tsx ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Bot, Send, User, Tag, Database, GitBranch, Clock, Info, X } from "lucide-react";
4
+ import type { BuilderNode } from "@/lib/automations-api";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ interface LabeledFieldProps {
12
+ label: string;
13
+ required?: boolean;
14
+ children: React.ReactNode;
15
+ }
16
+
17
+ function LabeledField({ label, required, children }: LabeledFieldProps) {
18
+ return (
19
+ <div className="space-y-1.5">
20
+ <label className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
21
+ {label}
22
+ {required && <span className="text-red-500 ml-1">*</span>}
23
+ </label>
24
+ {children}
25
+ </div>
26
+ );
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Per-type config forms
31
+ // ---------------------------------------------------------------------------
32
+
33
+ function AiReplyConfig({
34
+ config,
35
+ onChange,
36
+ }: {
37
+ config: Record<string, any>;
38
+ onChange: (updated: Record<string, any>) => void;
39
+ }) {
40
+ const tasks: string[] = Array.isArray(config.tasks) ? config.tasks : [];
41
+
42
+ const addTask = () => onChange({ ...config, tasks: [...tasks, ""] });
43
+ const removeTask = (i: number) =>
44
+ onChange({ ...config, tasks: tasks.filter((_, idx) => idx !== i) });
45
+ const updateTask = (i: number, val: string) =>
46
+ onChange({ ...config, tasks: tasks.map((t, idx) => (idx === i ? val : t)) });
47
+
48
+ return (
49
+ <div className="space-y-4">
50
+ <LabeledField label="Goal" required>
51
+ <textarea
52
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-teal-500 min-h-[80px]"
53
+ placeholder="e.g. Greet the user and collect contact info"
54
+ value={config.goal ?? ""}
55
+ onChange={(e) => onChange({ ...config, goal: e.target.value })}
56
+ />
57
+ </LabeledField>
58
+
59
+ <LabeledField label="Tasks (optional)">
60
+ <div className="space-y-2">
61
+ {tasks.map((task, i) => (
62
+ <div key={i} className="flex gap-2">
63
+ <input
64
+ className="flex-1 rounded-lg border border-border bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
65
+ placeholder={`Task ${i + 1}`}
66
+ value={task}
67
+ onChange={(e) => updateTask(i, e.target.value)}
68
+ />
69
+ <button
70
+ onClick={() => removeTask(i)}
71
+ className="p-1.5 rounded-md text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950 transition-colors"
72
+ >
73
+ <X className="w-3.5 h-3.5" />
74
+ </button>
75
+ </div>
76
+ ))}
77
+ <button
78
+ onClick={addTask}
79
+ className="text-xs text-teal-600 hover:text-teal-700 font-medium"
80
+ >
81
+ + Add task
82
+ </button>
83
+ </div>
84
+ </LabeledField>
85
+
86
+ <LabeledField label="Extra Instructions (optional)">
87
+ <textarea
88
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-teal-500 min-h-[60px]"
89
+ placeholder="Any additional context for the AI..."
90
+ value={config.extra_instructions ?? ""}
91
+ onChange={(e) => onChange({ ...config, extra_instructions: e.target.value })}
92
+ />
93
+ </LabeledField>
94
+ </div>
95
+ );
96
+ }
97
+
98
+ function SendMessageConfig({
99
+ config,
100
+ onChange,
101
+ }: {
102
+ config: Record<string, any>;
103
+ onChange: (updated: Record<string, any>) => void;
104
+ }) {
105
+ return (
106
+ <LabeledField label="Message Content" required>
107
+ <textarea
108
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-teal-500 min-h-[120px]"
109
+ placeholder="Enter the message to send..."
110
+ value={config.content ?? ""}
111
+ onChange={(e) => onChange({ ...config, content: e.target.value })}
112
+ />
113
+ </LabeledField>
114
+ );
115
+ }
116
+
117
+ function HumanHandoverConfig({
118
+ config,
119
+ onChange,
120
+ }: {
121
+ config: Record<string, any>;
122
+ onChange: (updated: Record<string, any>) => void;
123
+ }) {
124
+ return (
125
+ <LabeledField label="Announcement Message (optional)">
126
+ <input
127
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
128
+ placeholder="e.g. Transferring you to a human agent..."
129
+ value={config.message ?? ""}
130
+ onChange={(e) => onChange({ ...config, message: e.target.value })}
131
+ />
132
+ </LabeledField>
133
+ );
134
+ }
135
+
136
+ function TagContactConfig({
137
+ config,
138
+ onChange,
139
+ }: {
140
+ config: Record<string, any>;
141
+ onChange: (updated: Record<string, any>) => void;
142
+ }) {
143
+ return (
144
+ <LabeledField label="Tag" required>
145
+ <input
146
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
147
+ placeholder="e.g. qualified, interested, vip"
148
+ value={config.tag ?? ""}
149
+ onChange={(e) => onChange({ ...config, tag: e.target.value })}
150
+ />
151
+ </LabeledField>
152
+ );
153
+ }
154
+
155
+ function ZohoUpsertConfig() {
156
+ return (
157
+ <div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 text-sm text-amber-800 dark:text-amber-300">
158
+ <Info className="w-4 h-4 shrink-0 mt-0.5" />
159
+ <p>
160
+ Contact data will be synced to your connected Zoho CRM using the workspace lead
161
+ mapping. Ensure Zoho integration is configured in Settings.
162
+ </p>
163
+ </div>
164
+ );
165
+ }
166
+
167
+ function ComingSoonConfig({ nodeType }: { nodeType: string }) {
168
+ return (
169
+ <div className="flex items-start gap-2 p-3 rounded-lg bg-gray-50 dark:bg-gray-900/40 border border-border text-sm text-muted-foreground">
170
+ <Info className="w-4 h-4 shrink-0 mt-0.5" />
171
+ <p>
172
+ <strong>{nodeType}</strong> is coming soon and cannot be used in published
173
+ automations yet. You can add it to the canvas for planning purposes.
174
+ </p>
175
+ </div>
176
+ );
177
+ }
178
+
179
+ function TriggerConfig({
180
+ config,
181
+ onChange,
182
+ }: {
183
+ config: Record<string, any>;
184
+ onChange: (updated: Record<string, any>) => void;
185
+ }) {
186
+ return (
187
+ <LabeledField label="Keywords (optional)">
188
+ <input
189
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
190
+ placeholder="hello, start, hi (comma-separated)"
191
+ value={(config.keywords ?? []).join(", ")}
192
+ onChange={(e) => {
193
+ const kw = e.target.value
194
+ .split(",")
195
+ .map((k) => k.trim())
196
+ .filter(Boolean);
197
+ onChange({ ...config, keywords: kw });
198
+ }}
199
+ />
200
+ <p className="text-xs text-muted-foreground">
201
+ Leave empty to trigger on any inbound message.
202
+ </p>
203
+ </LabeledField>
204
+ );
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // NodeConfigPanel
209
+ // ---------------------------------------------------------------------------
210
+
211
+ interface NodeConfigPanelProps {
212
+ node: BuilderNode | null;
213
+ onClose: () => void;
214
+ onUpdateConfig: (nodeId: string, config: Record<string, any>) => void;
215
+ }
216
+
217
+ const NODE_TYPE_ICONS: Record<string, React.ReactNode> = {
218
+ AI_REPLY: <Bot className="w-4 h-4" />,
219
+ SEND_MESSAGE: <Send className="w-4 h-4" />,
220
+ HUMAN_HANDOVER: <User className="w-4 h-4" />,
221
+ TAG_CONTACT: <Tag className="w-4 h-4" />,
222
+ ZOHO_UPSERT_LEAD: <Database className="w-4 h-4" />,
223
+ CONDITION: <GitBranch className="w-4 h-4" />,
224
+ WAIT_DELAY: <Clock className="w-4 h-4" />,
225
+ MESSAGE_INBOUND: <Bot className="w-4 h-4" />,
226
+ LEAD_AD_SUBMIT: <Bot className="w-4 h-4" />,
227
+ };
228
+
229
+ const NODE_TYPE_LABELS: Record<string, string> = {
230
+ AI_REPLY: "AI Reply",
231
+ SEND_MESSAGE: "Send Message",
232
+ HUMAN_HANDOVER: "Human Handover",
233
+ TAG_CONTACT: "Tag Contact",
234
+ ZOHO_UPSERT_LEAD: "Zoho: Upsert Lead",
235
+ CONDITION: "Condition",
236
+ WAIT_DELAY: "Wait / Delay",
237
+ MESSAGE_INBOUND: "Inbound Message",
238
+ LEAD_AD_SUBMIT: "Lead Ad Submission",
239
+ };
240
+
241
+ export default function NodeConfigPanel({ node, onClose, onUpdateConfig }: NodeConfigPanelProps) {
242
+ if (!node) {
243
+ return (
244
+ <div className="w-72 flex flex-col border-l border-border bg-background/80 items-center justify-center text-center px-6">
245
+ <div className="p-3 rounded-full bg-muted mb-3">
246
+ <Info className="w-6 h-6 text-muted-foreground" />
247
+ </div>
248
+ <p className="text-sm font-medium text-muted-foreground">
249
+ Select a node to configure it
250
+ </p>
251
+ </div>
252
+ );
253
+ }
254
+
255
+ const nodeType = node.data.nodeType;
256
+ const config = node.data.config ?? {};
257
+ const isTrigger = node.type === "triggerNode";
258
+
259
+ const handleChange = (updated: Record<string, any>) => {
260
+ onUpdateConfig(node.id, updated);
261
+ };
262
+
263
+ return (
264
+ <div className="w-72 flex flex-col border-l border-border bg-background overflow-y-auto">
265
+ {/* Header */}
266
+ <div className="flex items-center justify-between px-4 py-3 border-b border-border">
267
+ <div className="flex items-center gap-2">
268
+ <span className="text-muted-foreground">
269
+ {NODE_TYPE_ICONS[nodeType] ?? <Bot className="w-4 h-4" />}
270
+ </span>
271
+ <span className="text-sm font-semibold">
272
+ {NODE_TYPE_LABELS[nodeType] ?? nodeType}
273
+ </span>
274
+ {isTrigger && (
275
+ <span className="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-teal-100 dark:bg-teal-900 text-teal-600">
276
+ Trigger
277
+ </span>
278
+ )}
279
+ </div>
280
+ <button
281
+ onClick={onClose}
282
+ className="p-1 rounded-md hover:bg-muted transition-colors text-muted-foreground"
283
+ >
284
+ <X className="w-4 h-4" />
285
+ </button>
286
+ </div>
287
+
288
+ {/* Config body */}
289
+ <div className="px-4 py-4 flex-1 space-y-4">
290
+ {nodeType === "AI_REPLY" && (
291
+ <AiReplyConfig config={config} onChange={handleChange} />
292
+ )}
293
+ {nodeType === "SEND_MESSAGE" && (
294
+ <SendMessageConfig config={config} onChange={handleChange} />
295
+ )}
296
+ {nodeType === "HUMAN_HANDOVER" && (
297
+ <HumanHandoverConfig config={config} onChange={handleChange} />
298
+ )}
299
+ {nodeType === "TAG_CONTACT" && (
300
+ <TagContactConfig config={config} onChange={handleChange} />
301
+ )}
302
+ {nodeType === "ZOHO_UPSERT_LEAD" && <ZohoUpsertConfig />}
303
+ {(nodeType === "CONDITION" || nodeType === "WAIT_DELAY") && (
304
+ <ComingSoonConfig nodeType={NODE_TYPE_LABELS[nodeType] ?? nodeType} />
305
+ )}
306
+ {isTrigger && (nodeType === "MESSAGE_INBOUND") && (
307
+ <TriggerConfig config={config} onChange={handleChange} />
308
+ )}
309
+ {isTrigger && nodeType === "LEAD_AD_SUBMIT" && (
310
+ <div className="text-sm text-muted-foreground">
311
+ Triggered automatically when a Meta Lead Ad form is submitted. No
312
+ configuration required.
313
+ </div>
314
+ )}
315
+ </div>
316
+
317
+ {/* Node ID (debug) */}
318
+ <div className="px-4 py-2 border-t border-border">
319
+ <p className="text-[10px] text-muted-foreground/50 font-mono truncate">id: {node.id}</p>
320
+ </div>
321
+ </div>
322
+ );
323
+ }
frontend/src/app/(dashboard)/automations/[id]/NodePalette.tsx ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useCatalog, type CatalogEntry } from "@/lib/catalog";
4
+ import { Zap, Bot, Send, User, Tag, Database, GitBranch, Clock, GripVertical } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Icon map for catalog entries
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const ICON_MAP: Record<string, React.ReactNode> = {
12
+ bot: <Bot className="w-4 h-4" />,
13
+ send: <Send className="w-4 h-4" />,
14
+ user: <User className="w-4 h-4" />,
15
+ tag: <Tag className="w-4 h-4" />,
16
+ database: <Database className="w-4 h-4" />,
17
+ "git-branch": <GitBranch className="w-4 h-4" />,
18
+ clock: <Clock className="w-4 h-4" />,
19
+ message: <Zap className="w-4 h-4" />,
20
+ zap: <Zap className="w-4 h-4" />,
21
+ };
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // DraggablePaletteItem
25
+ // ---------------------------------------------------------------------------
26
+
27
+ interface PaletteItemProps {
28
+ nodeType: string;
29
+ label: string;
30
+ iconHint: string;
31
+ comingSoon?: boolean;
32
+ isTrigger?: boolean;
33
+ hasTrigger?: boolean;
34
+ }
35
+
36
+ function PaletteItem({
37
+ nodeType,
38
+ label,
39
+ iconHint,
40
+ comingSoon,
41
+ isTrigger,
42
+ hasTrigger,
43
+ }: PaletteItemProps) {
44
+ const icon = ICON_MAP[iconHint] ?? <Bot className="w-4 h-4" />;
45
+ const disabled = isTrigger && hasTrigger;
46
+
47
+ const onDragStart = (e: React.DragEvent) => {
48
+ if (disabled) {
49
+ e.preventDefault();
50
+ return;
51
+ }
52
+ e.dataTransfer.effectAllowed = "move";
53
+ e.dataTransfer.setData(
54
+ "application/reactflow-node",
55
+ JSON.stringify({ nodeType, isTrigger })
56
+ );
57
+ };
58
+
59
+ return (
60
+ <div
61
+ draggable={!disabled}
62
+ onDragStart={onDragStart}
63
+ className={cn(
64
+ "flex items-center gap-2.5 px-3 py-2 rounded-lg border transition-colors cursor-grab active:cursor-grabbing select-none",
65
+ disabled
66
+ ? "opacity-40 cursor-not-allowed border-border bg-card"
67
+ : "border-border bg-card hover:border-teal-400 hover:bg-teal-50 dark:hover:bg-teal-950/40"
68
+ )}
69
+ >
70
+ <GripVertical className="w-3 h-3 text-muted-foreground/50 shrink-0" />
71
+ <span className="text-foreground/70">{icon}</span>
72
+ <span className="text-sm font-medium text-foreground flex-1 truncate">{label}</span>
73
+ {comingSoon && (
74
+ <span className="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 shrink-0">
75
+ Soon
76
+ </span>
77
+ )}
78
+ {disabled && (
79
+ <span className="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-teal-100 dark:bg-teal-900 text-teal-600 shrink-0">
80
+ Added
81
+ </span>
82
+ )}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // NodePalette
89
+ // ---------------------------------------------------------------------------
90
+
91
+ interface NodePaletteProps {
92
+ hasTrigger: boolean;
93
+ }
94
+
95
+ export default function NodePalette({ hasTrigger }: NodePaletteProps) {
96
+ const { data: nodeTypes } = useCatalog<CatalogEntry[]>("automation-node-types");
97
+ const { data: triggerTypes } = useCatalog<CatalogEntry[]>("automation-trigger-types");
98
+
99
+ return (
100
+ <div className="w-64 flex flex-col border-r border-border bg-background/80 overflow-y-auto">
101
+ <div className="px-4 py-3 border-b border-border">
102
+ <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
103
+ Node Palette
104
+ </p>
105
+ <p className="text-xs text-muted-foreground mt-0.5">Drag nodes onto the canvas</p>
106
+ </div>
107
+
108
+ {/* Triggers */}
109
+ <div className="px-3 py-3 space-y-1.5">
110
+ <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground px-1 mb-2">
111
+ Triggers
112
+ </p>
113
+ {triggerTypes?.map((t) => (
114
+ <PaletteItem
115
+ key={t.key}
116
+ nodeType={t.key}
117
+ label={t.label}
118
+ iconHint={t.icon_hint ?? "zap"}
119
+ isTrigger
120
+ hasTrigger={hasTrigger}
121
+ />
122
+ )) ?? (
123
+ <div className="space-y-1.5">
124
+ <PaletteItem
125
+ nodeType="MESSAGE_INBOUND"
126
+ label="Inbound Message"
127
+ iconHint="message"
128
+ isTrigger
129
+ hasTrigger={hasTrigger}
130
+ />
131
+ <PaletteItem
132
+ nodeType="LEAD_AD_SUBMIT"
133
+ label="Lead Ad Submission"
134
+ iconHint="zap"
135
+ isTrigger
136
+ hasTrigger={hasTrigger}
137
+ />
138
+ </div>
139
+ )}
140
+ </div>
141
+
142
+ {/* Divider */}
143
+ <div className="mx-3 border-t border-border" />
144
+
145
+ {/* Action Nodes */}
146
+ <div className="px-3 py-3 space-y-1.5">
147
+ <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground px-1 mb-2">
148
+ Actions
149
+ </p>
150
+ {nodeTypes?.map((n) => (
151
+ <PaletteItem
152
+ key={n.key}
153
+ nodeType={n.key}
154
+ label={n.label}
155
+ iconHint={n.icon_hint ?? "bot"}
156
+ comingSoon={n.runtime_supported === false}
157
+ />
158
+ )) ?? (
159
+ <div className="space-y-1.5">
160
+ {[
161
+ { key: "AI_REPLY", label: "AI Reply", iconHint: "bot" },
162
+ { key: "SEND_MESSAGE", label: "Send Message", iconHint: "send" },
163
+ { key: "HUMAN_HANDOVER", label: "Human Handover", iconHint: "user" },
164
+ { key: "TAG_CONTACT", label: "Tag Contact", iconHint: "tag" },
165
+ { key: "ZOHO_UPSERT_LEAD", label: "Zoho: Upsert Lead", iconHint: "database" },
166
+ { key: "CONDITION", label: "Condition", iconHint: "git-branch", comingSoon: true },
167
+ { key: "WAIT_DELAY", label: "Wait / Delay", iconHint: "clock", comingSoon: true },
168
+ ].map((n) => (
169
+ <PaletteItem
170
+ key={n.key}
171
+ nodeType={n.key}
172
+ label={n.label}
173
+ iconHint={n.iconHint}
174
+ comingSoon={"comingSoon" in n ? n.comingSoon : false}
175
+ />
176
+ ))}
177
+ </div>
178
+ )}
179
+ </div>
180
+ </div>
181
+ );
182
+ }
frontend/src/app/(dashboard)/automations/[id]/nodes/ActionNode.tsx ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { memo } from "react";
4
+ import { Handle, Position, type NodeProps } from "@xyflow/react";
5
+ import {
6
+ Bot,
7
+ Send,
8
+ User,
9
+ Tag,
10
+ Database,
11
+ GitBranch,
12
+ Clock,
13
+ X,
14
+ AlertCircle,
15
+ } from "lucide-react";
16
+ import { cn } from "@/lib/utils";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Node type metadata
20
+ // ---------------------------------------------------------------------------
21
+
22
+ interface NodeMeta {
23
+ label: string;
24
+ icon: React.ReactNode;
25
+ colorClass: string; // Tailwind border + header color
26
+ summary: (config: Record<string, any>) => string | null;
27
+ comingSoon?: boolean;
28
+ }
29
+
30
+ const NODE_META: Record<string, NodeMeta> = {
31
+ AI_REPLY: {
32
+ label: "AI Reply",
33
+ icon: <Bot className="w-4 h-4" />,
34
+ colorClass: "border-violet-400",
35
+ summary: (c) => c.goal ? `Goal: ${String(c.goal).substring(0, 40)}` : null,
36
+ },
37
+ SEND_MESSAGE: {
38
+ label: "Send Message",
39
+ icon: <Send className="w-4 h-4" />,
40
+ colorClass: "border-blue-400",
41
+ summary: (c) => c.content ? `"${String(c.content).substring(0, 40)}"` : null,
42
+ },
43
+ HUMAN_HANDOVER: {
44
+ label: "Human Handover",
45
+ icon: <User className="w-4 h-4" />,
46
+ colorClass: "border-orange-400",
47
+ summary: () => "Route to human agent",
48
+ },
49
+ TAG_CONTACT: {
50
+ label: "Tag Contact",
51
+ icon: <Tag className="w-4 h-4" />,
52
+ colorClass: "border-pink-400",
53
+ summary: (c) => c.tag ? `Tag: ${c.tag}` : null,
54
+ },
55
+ ZOHO_UPSERT_LEAD: {
56
+ label: "Zoho: Upsert Lead",
57
+ icon: <Database className="w-4 h-4" />,
58
+ colorClass: "border-amber-400",
59
+ summary: () => "Sync to Zoho CRM",
60
+ },
61
+ CONDITION: {
62
+ label: "Condition",
63
+ icon: <GitBranch className="w-4 h-4" />,
64
+ colorClass: "border-gray-400",
65
+ summary: () => "Branch logic",
66
+ comingSoon: true,
67
+ },
68
+ WAIT_DELAY: {
69
+ label: "Wait / Delay",
70
+ icon: <Clock className="w-4 h-4" />,
71
+ colorClass: "border-gray-400",
72
+ summary: () => "Pause flow",
73
+ comingSoon: true,
74
+ },
75
+ };
76
+
77
+ const FALLBACK_META: NodeMeta = {
78
+ label: "Action",
79
+ icon: <Bot className="w-4 h-4" />,
80
+ colorClass: "border-gray-400",
81
+ summary: () => null,
82
+ };
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Component
86
+ // ---------------------------------------------------------------------------
87
+
88
+ interface ActionNodeData {
89
+ nodeType: string;
90
+ config: Record<string, any>;
91
+ hasError?: boolean;
92
+ onDelete?: (id: string) => void;
93
+ [key: string]: unknown;
94
+ }
95
+
96
+ function ActionNode({ id, data, selected }: NodeProps) {
97
+ const nodeType = (data as ActionNodeData).nodeType;
98
+ const config = (data as ActionNodeData).config ?? {};
99
+ const hasError = (data as ActionNodeData).hasError ?? false;
100
+ const onDelete = (data as ActionNodeData).onDelete;
101
+
102
+ const meta = NODE_META[nodeType] ?? FALLBACK_META;
103
+ const summaryText = meta.summary(config);
104
+
105
+ return (
106
+ <div
107
+ className={cn(
108
+ "relative group w-56 rounded-xl border-2 bg-card shadow-md transition-all",
109
+ hasError ? "border-red-500 shadow-red-100" : meta.colorClass,
110
+ selected ? "shadow-lg" : "",
111
+ "hover:shadow-lg"
112
+ )}
113
+ >
114
+ {/* Input handle */}
115
+ <Handle
116
+ type="target"
117
+ position={Position.Top}
118
+ className="!w-3 !h-3 !bg-foreground !border-2 !border-white"
119
+ />
120
+
121
+ {/* Delete button — appears on hover */}
122
+ {onDelete && (
123
+ <button
124
+ onClick={() => onDelete(id)}
125
+ className="absolute -top-2 -right-2 hidden group-hover:flex items-center justify-center w-5 h-5 rounded-full bg-red-500 text-white shadow-sm hover:bg-red-600 transition-colors z-10"
126
+ title="Remove node"
127
+ >
128
+ <X className="w-3 h-3" />
129
+ </button>
130
+ )}
131
+
132
+ {/* Body */}
133
+ <div className="px-3 py-3 space-y-1.5">
134
+ <div className="flex items-center justify-between gap-2">
135
+ <div className="flex items-center gap-2 text-sm font-medium text-foreground">
136
+ {meta.icon}
137
+ <span>{meta.label}</span>
138
+ </div>
139
+ {meta.comingSoon && (
140
+ <span className="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 shrink-0">
141
+ Soon
142
+ </span>
143
+ )}
144
+ </div>
145
+
146
+ {summaryText && (
147
+ <p className="text-xs text-muted-foreground truncate pl-6">{summaryText}</p>
148
+ )}
149
+
150
+ {hasError && (
151
+ <div className="flex items-center gap-1 text-xs text-red-600">
152
+ <AlertCircle className="w-3 h-3 shrink-0" />
153
+ <span>Configuration required</span>
154
+ </div>
155
+ )}
156
+ </div>
157
+
158
+ {/* Output handle */}
159
+ <Handle
160
+ type="source"
161
+ position={Position.Bottom}
162
+ className="!w-3 !h-3 !bg-foreground !border-2 !border-white"
163
+ />
164
+ </div>
165
+ );
166
+ }
167
+
168
+ export default memo(ActionNode);
frontend/src/app/(dashboard)/automations/[id]/nodes/TriggerNode.tsx ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { memo } from "react";
4
+ import { Handle, Position, type NodeProps } from "@xyflow/react";
5
+ import { Zap, MessageSquare, MousePointerClick } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ const TRIGGER_ICONS: Record<string, React.ReactNode> = {
9
+ MESSAGE_INBOUND: <MessageSquare className="w-4 h-4" />,
10
+ LEAD_AD_SUBMIT: <MousePointerClick className="w-4 h-4" />,
11
+ };
12
+
13
+ const TRIGGER_LABELS: Record<string, string> = {
14
+ MESSAGE_INBOUND: "Inbound Message",
15
+ LEAD_AD_SUBMIT: "Lead Ad Submission",
16
+ };
17
+
18
+ const PLATFORM_LABELS: Record<string, string> = {
19
+ whatsapp: "WhatsApp",
20
+ meta: "Meta (Messenger / IG)",
21
+ };
22
+
23
+ function TriggerNode({ data, selected }: NodeProps) {
24
+ const nodeType = data.nodeType as string;
25
+ const platform = data.platform as string | undefined;
26
+
27
+ return (
28
+ <div
29
+ className={cn(
30
+ "relative w-56 rounded-xl border-2 bg-card shadow-md transition-shadow",
31
+ selected ? "border-teal-500 shadow-teal-200" : "border-teal-400",
32
+ "hover:shadow-lg"
33
+ )}
34
+ >
35
+ {/* Header */}
36
+ <div className="flex items-center gap-2 px-3 py-2 bg-teal-50 dark:bg-teal-950 rounded-t-xl border-b border-teal-200 dark:border-teal-800">
37
+ <div className="p-1 rounded-md bg-teal-600 text-white">
38
+ <Zap className="w-3 h-3" />
39
+ </div>
40
+ <span className="text-xs font-semibold uppercase tracking-wide text-teal-700 dark:text-teal-300">
41
+ Trigger
42
+ </span>
43
+ </div>
44
+
45
+ {/* Body */}
46
+ <div className="px-3 py-3 space-y-1">
47
+ <div className="flex items-center gap-2 text-sm font-medium text-foreground">
48
+ {TRIGGER_ICONS[nodeType] ?? <Zap className="w-4 h-4" />}
49
+ <span>{TRIGGER_LABELS[nodeType] ?? nodeType}</span>
50
+ </div>
51
+ {platform && (
52
+ <p className="text-xs text-muted-foreground pl-6">
53
+ {PLATFORM_LABELS[platform] ?? platform}
54
+ </p>
55
+ )}
56
+ </div>
57
+
58
+ {/* Output handle */}
59
+ <Handle
60
+ type="source"
61
+ position={Position.Bottom}
62
+ className="!w-3 !h-3 !bg-teal-500 !border-2 !border-white"
63
+ />
64
+ </div>
65
+ );
66
+ }
67
+
68
+ export default memo(TriggerNode);
frontend/src/app/(dashboard)/automations/[id]/page.tsx CHANGED
@@ -1,222 +1,850 @@
1
  "use client";
2
 
3
- import { useState, useEffect } from "react";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import {
5
- Zap,
6
  ArrowLeft,
7
- Save,
8
- Send,
9
- Rocket,
10
- Settings2,
11
- FileJson,
12
  CheckCircle2,
13
- Loader2
 
 
 
 
 
 
 
 
14
  } from "lucide-react";
15
  import Link from "next/link";
16
- import { useParams } from "next/navigation";
17
  import { cn } from "@/lib/utils";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
- export default function FlowDetailPage() {
20
- const params = useParams();
21
- const id = params.id as string;
22
- const [flow, setFlow] = useState<any>(null);
23
- const [isLoading, setIsLoading] = useState(true);
24
- const [showAdvanced, setShowAdvanced] = useState(false);
25
- const [isPublishing, setIsPublishing] = useState(false);
26
- const [status, setStatus] = useState<string>("");
27
 
28
- useEffect(() => {
29
- const fetchFlow = async () => {
30
- try {
31
- const res = await fetch(`/api/v1/automations/${id}`, {
32
- headers: { 'X-Workspace-ID': 'dummy' } // In real app, get from context
33
- });
34
- const json = await res.json();
35
- if (json.success) setFlow(json.data);
36
- } catch (err) {
37
- console.error("Failed to fetch flow", err);
38
- } finally {
39
- setIsLoading(false);
40
- }
41
- };
42
- if (id) fetchFlow();
43
- }, [id]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
- const handlePublish = async () => {
46
- setIsPublishing(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  try {
48
- const res = await fetch(`/api/v1/automations/${id}/publish`, { method: 'POST' });
49
- const json = await res.json();
50
- if (json.success) setStatus("published");
51
- } catch (err) {
52
- console.error("Publish failed", err);
53
- } finally {
54
- setIsPublishing(false);
55
  }
 
 
 
56
  };
57
 
58
- if (isLoading) return (
59
- <div className="flex items-center justify-center h-screen">
60
- <Loader2 className="w-8 h-8 animate-spin text-teal-600" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  </div>
62
  );
 
63
 
64
- if (!flow) return <div className="p-8 text-center text-red-500 font-medium">Flow not found.</div>;
 
 
65
 
66
- const definition = flow.definition || {};
67
- const nodes = definition.nodes || [];
68
- const triggerNode = nodes.find((n: any) => n.type === 'TRIGGER');
69
- const actionNodes = nodes.filter((n: any) => n.type !== 'TRIGGER');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  return (
72
- <div className="h-full flex flex-col bg-slate-50/50">
73
- {/* Detail Header */}
74
- <header className="bg-white border-b border-border p-4 sticky top-0 z-10 shadow-sm">
75
- <div className="max-w-7xl mx-auto flex items-center justify-between">
76
- <div className="flex items-center gap-4">
77
- <Link
78
- href="/automations"
79
- className="p-2 hover:bg-slate-100 rounded-lg text-slate-500 transition-colors"
80
- >
81
- <ArrowLeft className="w-5 h-5" />
82
- </Link>
83
- <div>
84
- <div className="flex items-center gap-2">
85
- <h1 className="text-xl font-bold text-slate-900">{flow.name}</h1>
86
- <span className={cn(
87
- "text-[10px] uppercase px-1.5 py-0.5 rounded-md font-bold",
88
- flow.status === "published" || status === "published"
89
- ? "bg-teal-100 text-teal-700"
90
- : "bg-slate-200 text-slate-600"
91
- )}>
92
- {status === "published" ? "published" : flow.status}
93
- </span>
94
- </div>
95
- <p className="text-sm text-muted-foreground">ID: {id}</p>
96
- </div>
97
- </div>
98
 
99
- <div className="flex items-center gap-3">
100
- <button className="flex items-center gap-2 px-4 py-2 text-slate-600 font-medium hover:bg-slate-100 rounded-lg transition-colors">
101
- <Save className="w-4 h-4" />
102
- Save Draft
103
- </button>
104
- <button
105
- onClick={handlePublish}
106
- disabled={isPublishing || flow.status === "published" || status === "published"}
107
- className={cn(
108
- "flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-all shadow-sm",
109
- (flow.status === "published" || status === "published")
110
- ? "bg-slate-100 text-slate-400 cursor-not-allowed"
111
- : "bg-teal-600 hover:bg-teal-700 text-white"
112
- )}
113
- >
114
- {isPublishing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Rocket className="w-4 h-4" />}
115
- {(flow.status === "published" || status === "published") ? "Published" : "Publish Flow"}
116
- </button>
117
  </div>
118
- </div>
119
- </header>
120
-
121
- {/* Main Content Areas */}
122
- <main className="flex-1 p-8 max-w-7xl mx-auto w-full grid grid-cols-1 lg:grid-cols-3 gap-8">
123
- {/* Summary View */}
124
- <div className="lg:col-span-2 space-y-6">
125
- <div className="space-y-4">
126
- <h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
127
- <Zap className="w-5 h-5 text-teal-600" />
128
- Automation Summary
129
- </h2>
130
-
131
- {/* Trigger Card */}
132
- <div className="bg-white border border-border rounded-2xl p-6 shadow-sm ring-1 ring-teal-500/5">
133
- <div className="text-xs font-bold text-teal-600 uppercase tracking-widest mb-3">Trigger Event</div>
134
- <div className="flex items-center gap-4">
135
- <div className="p-3 bg-teal-50 rounded-xl text-teal-600">
136
- <Zap className="w-6 h-6 fill-current" />
137
- </div>
138
- <div>
139
- <h3 className="font-bold text-slate-900">{triggerNode?.config?.type?.replace('_', ' ') || 'Incoming Message'}</h3>
140
- <p className="text-sm text-muted-foreground">Provider: {triggerNode?.config?.platform?.toUpperCase()}</p>
141
- </div>
142
- </div>
143
  </div>
144
-
145
- {/* Steps List */}
146
- <div className="space-y-3">
147
- <div className="text-xs font-bold text-slate-400 uppercase tracking-widest pl-1">Actions & Steps</div>
148
- {actionNodes.map((node: any, idx: number) => (
149
- <div key={node.id} className="bg-white border border-border rounded-2xl p-5 shadow-sm flex items-center gap-4 relative">
150
- <div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-xs font-bold text-slate-500 border border-border">
151
- {idx + 1}
152
- </div>
153
- <div className="flex-1">
154
- <div className="font-bold text-slate-700 flex items-center gap-2 capitalize">
155
- {node.type.replace('_', ' ').toLowerCase()}
156
- </div>
157
- <p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
158
- {node.type === 'AI_REPLY' ? 'Generates an AI response using Prompt Studio.' :
159
- node.type === 'SEND_MESSAGE' ? (node.config?.text || 'Standard message intent.') :
160
- node.type === 'HUMAN_HANDOVER' ? 'Notifies a team member for takeover.' : 'Processes contact update.'}
161
- </p>
 
 
 
162
  </div>
163
- <button className="p-2 text-slate-300 hover:text-teal-600 transition-colors">
164
- <Settings2 className="w-4 h-4" />
165
- </button>
 
 
 
 
 
 
 
 
 
 
 
166
  </div>
167
  ))}
168
  </div>
 
 
 
 
 
 
169
 
170
- {/* Advanced Collapsible */}
171
- <div className="pt-4">
172
- <button
173
- onClick={() => setShowAdvanced(!showAdvanced)}
174
- className="text-sm text-slate-400 font-medium flex items-center gap-2 hover:text-slate-600 transition-colors"
175
- >
176
- <Settings2 className={cn("w-4 h-4 transition-transform", showAdvanced && "rotate-90")} />
177
- {showAdvanced ? "Hide Advanced Definition" : "View Advanced Definition (JSON)"}
178
- </button>
179
-
180
- {showAdvanced && (
181
- <div className="mt-4 bg-slate-900 rounded-2xl p-6 overflow-x-auto border border-slate-800 animate-in fade-in slide-in-from-top-4">
182
- <pre className="text-xs font-mono text-teal-400">
183
- {JSON.stringify(definition, null, 2)}
184
- </pre>
185
- </div>
186
- )}
187
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  </div>
 
189
  </div>
190
 
191
- {/* Sidebar Info */}
192
- <div className="space-y-6">
193
- <div className="bg-white border border-border rounded-2xl p-6 shadow-sm space-y-4">
194
- <h2 className="font-semibold text-slate-900 border-b pb-2">Flow Stats</h2>
195
- <div className="space-y-3 px-1">
196
- <div className="flex justify-between items-center text-sm">
197
- <span className="text-muted-foreground">Created</span>
198
- <span className="font-medium">Today</span>
199
- </div>
200
- <div className="flex justify-between items-center text-sm">
201
- <span className="text-muted-foreground">Version</span>
202
- <span className="font-medium bg-slate-100 px-1.5 py-0.5 rounded text-[10px]">v1.0</span>
203
- </div>
204
- <div className="flex justify-between items-center text-sm">
205
- <span className="text-muted-foreground">Total Steps</span>
206
- <span className="font-medium">{actionNodes.length + 1}</span>
207
- </div>
208
- </div>
209
- </div>
210
 
211
- <div className="bg-white border border-border rounded-2xl p-6 shadow-sm space-y-4">
212
- <h2 className="font-semibold text-slate-900 border-b pb-2">Recent Logs</h2>
213
- <div className="flex flex-col items-center justify-center pt-4 text-center">
214
- <Zap className="w-8 h-8 text-teal-400 mb-2 opacity-30" />
215
- <p className="text-sm text-muted-foreground italic">No recent executions recorded.</p>
216
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  </div>
 
 
 
 
 
 
218
  </div>
219
- </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  </div>
221
  );
222
  }
 
1
  "use client";
2
 
3
+ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
4
+ import { useParams, useRouter } from "next/navigation";
5
+ import ReactFlow, {
6
+ Background,
7
+ Controls,
8
+ MiniMap,
9
+ addEdge,
10
+ useNodesState,
11
+ useEdgesState,
12
+ type Node,
13
+ type Edge,
14
+ type OnConnect,
15
+ type NodeTypes,
16
+ BackgroundVariant,
17
+ ReactFlowProvider,
18
+ useReactFlow,
19
+ } from "@xyflow/react";
20
+ import "@xyflow/react/dist/style.css";
21
+
22
  import {
 
23
  ArrowLeft,
 
 
 
 
 
24
  CheckCircle2,
25
+ AlertTriangle,
26
+ Loader2,
27
+ Play,
28
+ Rocket,
29
+ RotateCcw,
30
+ ChevronDown,
31
+ X,
32
+ Save,
33
+ Clock,
34
  } from "lucide-react";
35
  import Link from "next/link";
 
36
  import { cn } from "@/lib/utils";
37
+ import {
38
+ getDraft,
39
+ saveDraft,
40
+ validateDraft,
41
+ publishDraft,
42
+ getVersions,
43
+ rollbackToVersion,
44
+ simulate,
45
+ getFlow,
46
+ type BuilderGraph,
47
+ type BuilderNode,
48
+ type BuilderEdge,
49
+ type ValidationError,
50
+ type FlowVersion,
51
+ type SimulateStep,
52
+ } from "@/lib/automations-api";
53
 
54
+ import TriggerNode from "./nodes/TriggerNode";
55
+ import ActionNode from "./nodes/ActionNode";
56
+ import NodePalette from "./NodePalette";
57
+ import NodeConfigPanel from "./NodeConfigPanel";
 
 
 
 
58
 
59
+ // ---------------------------------------------------------------------------
60
+ // React Flow node types registration
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const nodeTypes: NodeTypes = {
64
+ triggerNode: TriggerNode,
65
+ actionNode: ActionNode,
66
+ };
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Helpers
70
+ // ---------------------------------------------------------------------------
71
+
72
+ function builderNodesToRF(
73
+ builderNodes: BuilderNode[],
74
+ errorNodeIds: Set<string>,
75
+ onDelete: (id: string) => void
76
+ ): Node[] {
77
+ return builderNodes.map((n) => ({
78
+ id: n.id,
79
+ type: n.type,
80
+ position: n.position,
81
+ data: {
82
+ ...n.data,
83
+ hasError: errorNodeIds.has(n.id),
84
+ onDelete: n.type === "actionNode" ? onDelete : undefined,
85
+ },
86
+ }));
87
+ }
88
+
89
+ function rfNodesToBuilder(rfNodes: Node[]): BuilderNode[] {
90
+ return rfNodes.map((n) => ({
91
+ id: n.id,
92
+ type: n.type as "triggerNode" | "actionNode",
93
+ position: n.position,
94
+ data: {
95
+ nodeType: n.data.nodeType as string,
96
+ platform: n.data.platform as string | undefined,
97
+ config: (n.data.config as Record<string, any>) ?? {},
98
+ },
99
+ }));
100
+ }
101
+
102
+ function rfEdgesToBuilder(rfEdges: Edge[]): BuilderEdge[] {
103
+ return rfEdges.map((e) => ({
104
+ id: e.id,
105
+ source: e.source,
106
+ target: e.target,
107
+ sourceHandle: e.sourceHandle ?? undefined,
108
+ }));
109
+ }
110
+
111
+ function generateNodeId(): string {
112
+ return `node-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Save status badge
117
+ // ---------------------------------------------------------------------------
118
+
119
+ type SaveStatus = "idle" | "saving" | "saved" | "error";
120
+
121
+ function SaveBadge({ status }: { status: SaveStatus }) {
122
+ if (status === "idle") return null;
123
+ return (
124
+ <div
125
+ className={cn(
126
+ "flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded-full",
127
+ status === "saving" && "text-muted-foreground bg-muted",
128
+ status === "saved" && "text-teal-700 bg-teal-100 dark:bg-teal-950 dark:text-teal-300",
129
+ status === "error" && "text-red-700 bg-red-100 dark:bg-red-950 dark:text-red-300"
130
+ )}
131
+ >
132
+ {status === "saving" && <Loader2 className="w-3 h-3 animate-spin" />}
133
+ {status === "saved" && <CheckCircle2 className="w-3 h-3" />}
134
+ {status === "error" && <AlertTriangle className="w-3 h-3" />}
135
+ {status === "saving" ? "Saving…" : status === "saved" ? "Saved" : "Save failed"}
136
+ </div>
137
+ );
138
+ }
139
 
140
+ // ---------------------------------------------------------------------------
141
+ // Simulate Drawer
142
+ // ---------------------------------------------------------------------------
143
+
144
+ function SimulateDrawer({
145
+ flowId,
146
+ onClose,
147
+ }: {
148
+ flowId: string;
149
+ onClose: () => void;
150
+ }) {
151
+ const [mockPayload, setMockPayload] = useState('{"content": "Hello", "sender": "+1234567890"}');
152
+ const [result, setResult] = useState<{
153
+ valid: boolean;
154
+ steps: SimulateStep[];
155
+ dispatch_blocked: boolean;
156
+ message: string;
157
+ errors?: ValidationError[];
158
+ } | null>(null);
159
+ const [loading, setLoading] = useState(false);
160
+ const [payloadError, setPayloadError] = useState("");
161
+
162
+ const run = async () => {
163
+ setLoading(true);
164
+ setPayloadError("");
165
+ let parsed: Record<string, any> | undefined;
166
  try {
167
+ parsed = JSON.parse(mockPayload);
168
+ } catch {
169
+ setPayloadError("Invalid JSON — fix the payload above.");
170
+ setLoading(false);
171
+ return;
 
 
172
  }
173
+ const res = await simulate(flowId, parsed);
174
+ if (res.success && res.data) setResult(res.data);
175
+ setLoading(false);
176
  };
177
 
178
+ return (
179
+ <div className="absolute right-0 top-0 h-full w-96 bg-background border-l border-border shadow-xl z-30 flex flex-col">
180
+ <div className="flex items-center justify-between px-4 py-3 border-b border-border">
181
+ <div className="flex items-center gap-2">
182
+ <Play className="w-4 h-4 text-teal-600" />
183
+ <span className="text-sm font-semibold">Simulate</span>
184
+ </div>
185
+ <button onClick={onClose} className="p-1 rounded hover:bg-muted text-muted-foreground">
186
+ <X className="w-4 h-4" />
187
+ </button>
188
+ </div>
189
+
190
+ <div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
191
+ <div className="space-y-2">
192
+ <label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
193
+ Mock Trigger Payload (JSON)
194
+ </label>
195
+ <textarea
196
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-xs font-mono resize-none focus:outline-none focus:ring-2 focus:ring-teal-500 min-h-[100px]"
197
+ value={mockPayload}
198
+ onChange={(e) => setMockPayload(e.target.value)}
199
+ />
200
+ {payloadError && <p className="text-xs text-red-500">{payloadError}</p>}
201
+ </div>
202
+
203
+ <button
204
+ onClick={run}
205
+ disabled={loading}
206
+ className="w-full flex items-center justify-center gap-2 bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
207
+ >
208
+ {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
209
+ Run Simulation
210
+ </button>
211
+
212
+ {result && (
213
+ <div className="space-y-3">
214
+ {!result.valid && result.errors && (
215
+ <div className="p-3 rounded-lg border border-red-200 bg-red-50 dark:bg-red-950/20 text-sm text-red-700 dark:text-red-300 space-y-1">
216
+ <p className="font-semibold">Validation errors:</p>
217
+ {result.errors.map((e, i) => (
218
+ <p key={i} className="text-xs">{e.node_id}: {e.message}</p>
219
+ ))}
220
+ </div>
221
+ )}
222
+ {result.valid && (
223
+ <>
224
+ <div className="flex items-center gap-2 text-xs text-teal-700 dark:text-teal-300 font-medium">
225
+ <CheckCircle2 className="w-3.5 h-3.5" />
226
+ No real messages sent — simulation mode
227
+ </div>
228
+ <div className="space-y-2">
229
+ {result.steps.map((step, i) => (
230
+ <div
231
+ key={i}
232
+ className="p-3 rounded-lg border border-border bg-card text-xs space-y-1"
233
+ >
234
+ <div className="flex items-center justify-between">
235
+ <span className="font-semibold text-foreground">
236
+ {i + 1}. {step.node_type}
237
+ </span>
238
+ <span className="text-muted-foreground text-[10px] bg-muted px-1.5 py-0.5 rounded-full">
239
+ blocked
240
+ </span>
241
+ </div>
242
+ <p className="text-muted-foreground">{step.description}</p>
243
+ {step.would_send && (
244
+ <p className="text-foreground italic">
245
+ Would send: &ldquo;{step.would_send}&rdquo;
246
+ </p>
247
+ )}
248
+ </div>
249
+ ))}
250
+ </div>
251
+ <p className="text-xs text-muted-foreground">{result.message}</p>
252
+ </>
253
+ )}
254
+ </div>
255
+ )}
256
+ </div>
257
  </div>
258
  );
259
+ }
260
 
261
+ // ---------------------------------------------------------------------------
262
+ // Versions Dropdown
263
+ // ---------------------------------------------------------------------------
264
 
265
+ function VersionsDropdown({
266
+ flowId,
267
+ onRollback,
268
+ }: {
269
+ flowId: string;
270
+ onRollback: (newVersionNumber: number) => void;
271
+ }) {
272
+ const [open, setOpen] = useState(false);
273
+ const [versions, setVersions] = useState<FlowVersion[]>([]);
274
+ const [loading, setLoading] = useState(false);
275
+ const [rolling, setRolling] = useState<string | null>(null);
276
+ const ref = useRef<HTMLDivElement>(null);
277
+
278
+ useEffect(() => {
279
+ if (!open) return;
280
+ setLoading(true);
281
+ getVersions(flowId).then((res) => {
282
+ if (res.success && res.data) setVersions(res.data);
283
+ setLoading(false);
284
+ });
285
+ }, [open, flowId]);
286
+
287
+ useEffect(() => {
288
+ const handler = (e: MouseEvent) => {
289
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
290
+ };
291
+ document.addEventListener("mousedown", handler);
292
+ return () => document.removeEventListener("mousedown", handler);
293
+ }, []);
294
+
295
+ const handleRollback = async (v: FlowVersion) => {
296
+ setRolling(v.id);
297
+ const res = await rollbackToVersion(flowId, v.id);
298
+ if (res.success && res.data) {
299
+ onRollback(res.data.new_version_number);
300
+ }
301
+ setRolling(null);
302
+ setOpen(false);
303
+ };
304
 
305
  return (
306
+ <div className="relative" ref={ref}>
307
+ <button
308
+ onClick={() => setOpen((o) => !o)}
309
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border hover:border-teal-400 text-sm font-medium text-foreground transition-colors"
310
+ >
311
+ <Clock className="w-4 h-4" />
312
+ Versions
313
+ <ChevronDown className={cn("w-3.5 h-3.5 transition-transform", open && "rotate-180")} />
314
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
 
316
+ {open && (
317
+ <div className="absolute right-0 top-10 z-20 w-72 rounded-xl border border-border bg-background shadow-xl overflow-hidden">
318
+ <div className="px-3 py-2.5 border-b border-border text-xs font-semibold uppercase tracking-wide text-muted-foreground">
319
+ Published Versions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  </div>
321
+ {loading ? (
322
+ <div className="flex items-center justify-center p-4">
323
+ <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  </div>
325
+ ) : versions.length === 0 ? (
326
+ <div className="px-4 py-3 text-sm text-muted-foreground">
327
+ No published versions yet.
328
+ </div>
329
+ ) : (
330
+ <div className="max-h-60 overflow-y-auto divide-y divide-border">
331
+ {versions.map((v) => (
332
+ <div
333
+ key={v.id}
334
+ className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/50"
335
+ >
336
+ <div className="flex items-center gap-2">
337
+ <span className="text-sm font-medium">v{v.version_number}</span>
338
+ {v.is_active && (
339
+ <span className="text-[10px] bg-teal-100 dark:bg-teal-900 text-teal-600 dark:text-teal-300 px-1.5 py-0.5 rounded-full font-semibold uppercase tracking-wide">
340
+ Live
341
+ </span>
342
+ )}
343
+ <span className="text-xs text-muted-foreground">
344
+ {new Date(v.created_at).toLocaleDateString()}
345
+ </span>
346
  </div>
347
+ {!v.is_active && (
348
+ <button
349
+ onClick={() => handleRollback(v)}
350
+ disabled={rolling === v.id}
351
+ className="flex items-center gap-1 text-xs text-teal-600 hover:text-teal-700 font-medium disabled:opacity-50"
352
+ >
353
+ {rolling === v.id ? (
354
+ <Loader2 className="w-3 h-3 animate-spin" />
355
+ ) : (
356
+ <RotateCcw className="w-3 h-3" />
357
+ )}
358
+ Rollback
359
+ </button>
360
+ )}
361
  </div>
362
  ))}
363
  </div>
364
+ )}
365
+ </div>
366
+ )}
367
+ </div>
368
+ );
369
+ }
370
 
371
+ // ---------------------------------------------------------------------------
372
+ // Main Canvas Editor (inner, needs ReactFlowProvider context)
373
+ // ---------------------------------------------------------------------------
374
+
375
+ function CanvasEditor({ flowId }: { flowId: string }) {
376
+ const router = useRouter();
377
+ const { screenToFlowPosition } = useReactFlow();
378
+
379
+ // Flow metadata
380
+ const [flowName, setFlowName] = useState("Loading…");
381
+
382
+ // React Flow state
383
+ const [rfNodes, setRfNodes, onNodesChange] = useNodesState([]);
384
+ const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState([]);
385
+
386
+ // UI state
387
+ const [selectedNode, setSelectedNode] = useState<BuilderNode | null>(null);
388
+ const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
389
+ const [validating, setValidating] = useState(false);
390
+ const [publishing, setPublishing] = useState(false);
391
+ const [errors, setErrors] = useState<ValidationError[]>([]);
392
+ const [publishResult, setPublishResult] = useState<{
393
+ success: boolean;
394
+ version_number?: number;
395
+ message?: string;
396
+ } | null>(null);
397
+ const [showSimulate, setShowSimulate] = useState(false);
398
+ const [loading, setLoading] = useState(true);
399
+
400
+ // Error node IDs set
401
+ const errorNodeIds = useMemo(
402
+ () => new Set(errors.map((e) => e.node_id)),
403
+ [errors]
404
+ );
405
+
406
+ // Does the canvas already have a trigger?
407
+ const hasTrigger = useMemo(
408
+ () => rfNodes.some((n) => n.type === "triggerNode"),
409
+ [rfNodes]
410
+ );
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Delete node handler
414
+ // ---------------------------------------------------------------------------
415
+
416
+ const handleDeleteNode = useCallback((nodeId: string) => {
417
+ setRfNodes((nds) => nds.filter((n) => n.id !== nodeId));
418
+ setRfEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
419
+ setSelectedNode((prev) => (prev?.id === nodeId ? null : prev));
420
+ }, [setRfNodes, setRfEdges]);
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // Load draft on mount
424
+ // ---------------------------------------------------------------------------
425
+
426
+ useEffect(() => {
427
+ let active = true;
428
+ async function load() {
429
+ const [flowRes, draftRes] = await Promise.all([
430
+ getFlow(flowId),
431
+ getDraft(flowId),
432
+ ]);
433
+
434
+ if (!active) return;
435
+
436
+ if (flowRes.success && flowRes.data) {
437
+ setFlowName(flowRes.data.name);
438
+ }
439
+
440
+ if (draftRes.success && draftRes.data?.builder_graph_json) {
441
+ const graph = draftRes.data.builder_graph_json;
442
+ const nodes = builderNodesToRF(graph.nodes, new Set(), handleDeleteNode);
443
+ setRfNodes(nodes);
444
+ setRfEdges(graph.edges as Edge[]);
445
+ // Restore validation errors if any
446
+ const errs = draftRes.data.last_validation_errors?.errors ?? [];
447
+ setErrors(errs);
448
+ }
449
+ setLoading(false);
450
+ }
451
+ load();
452
+ return () => { active = false; };
453
+ }, [flowId, setRfNodes, setRfEdges, handleDeleteNode]);
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Autosave (debounced)
457
+ // ---------------------------------------------------------------------------
458
+
459
+ const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
460
+
461
+ const triggerAutosave = useCallback(
462
+ (nodes: Node[], edges: Edge[]) => {
463
+ if (saveTimer.current) clearTimeout(saveTimer.current);
464
+ setSaveStatus("saving");
465
+ saveTimer.current = setTimeout(async () => {
466
+ const graph: BuilderGraph = {
467
+ nodes: rfNodesToBuilder(nodes),
468
+ edges: rfEdgesToBuilder(edges),
469
+ };
470
+ const res = await saveDraft(flowId, graph);
471
+ setSaveStatus(res.success ? "saved" : "error");
472
+ // Reset to idle after 3s
473
+ setTimeout(() => setSaveStatus("idle"), 3000);
474
+ }, 2000);
475
+ },
476
+ [flowId]
477
+ );
478
+
479
+ const handleNodesChange: typeof onNodesChange = useCallback(
480
+ (changes) => {
481
+ onNodesChange(changes);
482
+ setRfNodes((nds) => {
483
+ triggerAutosave(nds, rfEdges);
484
+ return nds;
485
+ });
486
+ },
487
+ [onNodesChange, setRfNodes, triggerAutosave, rfEdges]
488
+ );
489
+
490
+ const handleEdgesChange: typeof onEdgesChange = useCallback(
491
+ (changes) => {
492
+ onEdgesChange(changes);
493
+ setRfEdges((eds) => {
494
+ triggerAutosave(rfNodes, eds);
495
+ return eds;
496
+ });
497
+ },
498
+ [onEdgesChange, setRfEdges, triggerAutosave, rfNodes]
499
+ );
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // Connect handler
503
+ // ---------------------------------------------------------------------------
504
+
505
+ const onConnect: OnConnect = useCallback(
506
+ (connection) => {
507
+ const newEdges = addEdge({ ...connection, id: `e-${Date.now()}` }, rfEdges);
508
+ setRfEdges(newEdges);
509
+ triggerAutosave(rfNodes, newEdges);
510
+ },
511
+ [rfEdges, setRfEdges, rfNodes, triggerAutosave]
512
+ );
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Drop handler (from palette)
516
+ // ---------------------------------------------------------------------------
517
+
518
+ const onDrop: OnDrop = useCallback(
519
+ (event) => {
520
+ event.preventDefault();
521
+ const raw = event.dataTransfer.getData("application/reactflow-node");
522
+ if (!raw) return;
523
+ let parsed: { nodeType: string; isTrigger: boolean };
524
+ try { parsed = JSON.parse(raw); } catch { return; }
525
+
526
+ if (parsed.isTrigger && hasTrigger) return; // Only one trigger
527
+
528
+ const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
529
+
530
+ const newNode: Node = {
531
+ id: generateNodeId(),
532
+ type: parsed.isTrigger ? "triggerNode" : "actionNode",
533
+ position,
534
+ data: {
535
+ nodeType: parsed.nodeType,
536
+ platform: parsed.isTrigger ? "whatsapp" : undefined,
537
+ config: {},
538
+ onDelete: !parsed.isTrigger ? handleDeleteNode : undefined,
539
+ },
540
+ };
541
+
542
+ setRfNodes((nds) => {
543
+ const updated = [...nds, newNode];
544
+ triggerAutosave(updated, rfEdges);
545
+ return updated;
546
+ });
547
+ },
548
+ [hasTrigger, screenToFlowPosition, handleDeleteNode, setRfNodes, rfEdges, triggerAutosave]
549
+ );
550
+
551
+ const onDragOver = useCallback((event: React.DragEvent) => {
552
+ event.preventDefault();
553
+ event.dataTransfer.dropEffect = "move";
554
+ }, []);
555
+
556
+ // ---------------------------------------------------------------------------
557
+ // Node selection
558
+ // ---------------------------------------------------------------------------
559
+
560
+ const onNodeClick = useCallback(
561
+ (_: React.MouseEvent, node: Node) => {
562
+ setSelectedNode({
563
+ id: node.id,
564
+ type: node.type as "triggerNode" | "actionNode",
565
+ position: node.position,
566
+ data: {
567
+ nodeType: node.data.nodeType as string,
568
+ platform: node.data.platform as string | undefined,
569
+ config: (node.data.config as Record<string, any>) ?? {},
570
+ },
571
+ });
572
+ },
573
+ []
574
+ );
575
+
576
+ const onPaneClick = useCallback(() => setSelectedNode(null), []);
577
+
578
+ // ---------------------------------------------------------------------------
579
+ // Update node config (from config panel)
580
+ // ---------------------------------------------------------------------------
581
+
582
+ const handleUpdateConfig = useCallback(
583
+ (nodeId: string, config: Record<string, any>) => {
584
+ setRfNodes((nds) => {
585
+ const updated = nds.map((n) =>
586
+ n.id === nodeId ? { ...n, data: { ...n.data, config } } : n
587
+ );
588
+ triggerAutosave(updated, rfEdges);
589
+ return updated;
590
+ });
591
+ setSelectedNode((prev) =>
592
+ prev?.id === nodeId ? { ...prev, data: { ...prev.data, config } } : prev
593
+ );
594
+ },
595
+ [setRfNodes, rfEdges, triggerAutosave]
596
+ );
597
+
598
+ // ---------------------------------------------------------------------------
599
+ // Validate
600
+ // ---------------------------------------------------------------------------
601
+
602
+ const handleValidate = useCallback(async () => {
603
+ setValidating(true);
604
+ setPublishResult(null);
605
+ // First save latest state
606
+ const graph: BuilderGraph = {
607
+ nodes: rfNodesToBuilder(rfNodes),
608
+ edges: rfEdgesToBuilder(rfEdges),
609
+ };
610
+ await saveDraft(flowId, graph);
611
+ const res = await validateDraft(flowId);
612
+ setValidating(false);
613
+ if (res.success && res.data) {
614
+ setErrors(res.data.errors);
615
+ // Update node error state
616
+ const errorSet = new Set(res.data.errors.map((e) => e.node_id));
617
+ setRfNodes((nds) =>
618
+ nds.map((n) => ({ ...n, data: { ...n.data, hasError: errorSet.has(n.id) } }))
619
+ );
620
+ }
621
+ }, [flowId, rfNodes, rfEdges, setRfNodes]);
622
+
623
+ // ---------------------------------------------------------------------------
624
+ // Publish
625
+ // ---------------------------------------------------------------------------
626
+
627
+ const handlePublish = useCallback(async () => {
628
+ setPublishing(true);
629
+ setPublishResult(null);
630
+ // Save first
631
+ const graph: BuilderGraph = {
632
+ nodes: rfNodesToBuilder(rfNodes),
633
+ edges: rfEdgesToBuilder(rfEdges),
634
+ };
635
+ await saveDraft(flowId, graph);
636
+ const res = await publishDraft(flowId);
637
+ setPublishing(false);
638
+ if (res.success && res.data) {
639
+ if (res.data.success && res.data.published) {
640
+ setErrors([]);
641
+ setRfNodes((nds) =>
642
+ nds.map((n) => ({ ...n, data: { ...n.data, hasError: false } }))
643
+ );
644
+ setPublishResult({
645
+ success: true,
646
+ version_number: res.data.version_number,
647
+ message: `Published as v${res.data.version_number}`,
648
+ });
649
+ } else {
650
+ setErrors(res.data.errors ?? []);
651
+ const errorSet = new Set((res.data.errors ?? []).map((e) => e.node_id));
652
+ setRfNodes((nds) =>
653
+ nds.map((n) => ({ ...n, data: { ...n.data, hasError: errorSet.has(n.id) } }))
654
+ );
655
+ setPublishResult({
656
+ success: false,
657
+ message: `${res.data.errors?.length ?? 0} error(s) must be fixed before publishing.`,
658
+ });
659
+ }
660
+ }
661
+ }, [flowId, rfNodes, rfEdges, setRfNodes]);
662
+
663
+ // ---------------------------------------------------------------------------
664
+ // Render
665
+ // ---------------------------------------------------------------------------
666
+
667
+ if (loading) {
668
+ return (
669
+ <div className="flex h-full items-center justify-center">
670
+ <Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
671
+ </div>
672
+ );
673
+ }
674
+
675
+ return (
676
+ <div className="flex flex-col h-full">
677
+ {/* ── Top Bar ── */}
678
+ <div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-background z-10 shrink-0">
679
+ <div className="flex items-center gap-3">
680
+ <Link
681
+ href="/automations"
682
+ className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground transition-colors"
683
+ >
684
+ <ArrowLeft className="w-4 h-4" />
685
+ </Link>
686
+ <div>
687
+ <h1 className="text-sm font-semibold text-foreground leading-tight">{flowName}</h1>
688
+ <p className="text-xs text-muted-foreground">Canvas Editor</p>
689
  </div>
690
+ <SaveBadge status={saveStatus} />
691
  </div>
692
 
693
+ <div className="flex items-center gap-2">
694
+ {/* Validate */}
695
+ <button
696
+ onClick={handleValidate}
697
+ disabled={validating}
698
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border hover:border-teal-400 text-sm font-medium text-foreground transition-colors disabled:opacity-50"
699
+ >
700
+ {validating ? (
701
+ <Loader2 className="w-4 h-4 animate-spin" />
702
+ ) : (
703
+ <CheckCircle2 className="w-4 h-4" />
704
+ )}
705
+ Validate
706
+ </button>
 
 
 
 
 
707
 
708
+ {/* Simulate */}
709
+ <button
710
+ onClick={() => setShowSimulate((s) => !s)}
711
+ className={cn(
712
+ "flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors",
713
+ showSimulate
714
+ ? "border-teal-500 bg-teal-50 dark:bg-teal-950 text-teal-700 dark:text-teal-300"
715
+ : "border-border hover:border-teal-400 text-foreground"
716
+ )}
717
+ >
718
+ <Play className="w-4 h-4" />
719
+ Simulate
720
+ </button>
721
+
722
+ {/* Versions */}
723
+ <VersionsDropdown
724
+ flowId={flowId}
725
+ onRollback={(vn) =>
726
+ setPublishResult({ success: true, message: `Rolled back — now on v${vn}` })
727
+ }
728
+ />
729
+
730
+ {/* Publish */}
731
+ <button
732
+ onClick={handlePublish}
733
+ disabled={publishing}
734
+ className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium transition-colors disabled:opacity-50 shadow-sm"
735
+ >
736
+ {publishing ? (
737
+ <Loader2 className="w-4 h-4 animate-spin" />
738
+ ) : (
739
+ <Rocket className="w-4 h-4" />
740
+ )}
741
+ Publish
742
+ </button>
743
+ </div>
744
+ </div>
745
+
746
+ {/* ── Publish / Error Banner ── */}
747
+ {(publishResult || errors.length > 0) && (
748
+ <div
749
+ className={cn(
750
+ "px-4 py-2.5 text-sm border-b flex items-start gap-2 shrink-0",
751
+ publishResult?.success
752
+ ? "bg-teal-50 dark:bg-teal-950/30 border-teal-200 dark:border-teal-800 text-teal-700 dark:text-teal-300"
753
+ : "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300"
754
+ )}
755
+ >
756
+ {publishResult?.success ? (
757
+ <CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" />
758
+ ) : (
759
+ <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
760
+ )}
761
+ <div className="flex-1 space-y-0.5">
762
+ {publishResult?.message && <p className="font-medium">{publishResult.message}</p>}
763
+ {errors.length > 0 && !publishResult && (
764
+ <p className="font-medium">{errors.length} validation issue(s) found</p>
765
+ )}
766
+ {errors.length > 0 && (
767
+ <ul className="text-xs space-y-0.5 mt-1">
768
+ {errors.slice(0, 5).map((e, i) => (
769
+ <li key={i}>
770
+ <span className="font-mono">{e.node_id}</span>: {e.message}
771
+ </li>
772
+ ))}
773
+ </ul>
774
+ )}
775
  </div>
776
+ <button
777
+ onClick={() => { setPublishResult(null); setErrors([]); }}
778
+ className="p-0.5 hover:opacity-70"
779
+ >
780
+ <X className="w-3.5 h-3.5" />
781
+ </button>
782
  </div>
783
+ )}
784
+
785
+ {/* ── Three-Panel Layout ── */}
786
+ <div className="flex flex-1 overflow-hidden relative">
787
+ {/* Left: Palette */}
788
+ <NodePalette hasTrigger={hasTrigger} />
789
+
790
+ {/* Center: Canvas */}
791
+ <div className="flex-1 h-full" onDragOver={onDragOver}>
792
+ <ReactFlow
793
+ nodes={rfNodes.map((n) => ({
794
+ ...n,
795
+ data: {
796
+ ...n.data,
797
+ hasError: errorNodeIds.has(n.id),
798
+ onDelete: n.type === "actionNode" ? handleDeleteNode : undefined,
799
+ },
800
+ }))}
801
+ edges={rfEdges}
802
+ onNodesChange={handleNodesChange}
803
+ onEdgesChange={handleEdgesChange}
804
+ onConnect={onConnect}
805
+ onDrop={onDrop}
806
+ onDragOver={onDragOver}
807
+ onNodeClick={onNodeClick}
808
+ onPaneClick={onPaneClick}
809
+ nodeTypes={nodeTypes}
810
+ fitView
811
+ deleteKeyCode="Delete"
812
+ className="bg-[#fafafa] dark:bg-[#0a0a0a]"
813
+ >
814
+ <Background variant={BackgroundVariant.Dots} gap={16} size={1} />
815
+ <Controls />
816
+ <MiniMap nodeStrokeWidth={3} />
817
+ </ReactFlow>
818
+ </div>
819
+
820
+ {/* Right: Config Panel or Simulate Drawer */}
821
+ {showSimulate ? (
822
+ <SimulateDrawer flowId={flowId} onClose={() => setShowSimulate(false)} />
823
+ ) : (
824
+ <NodeConfigPanel
825
+ node={selectedNode}
826
+ onClose={() => setSelectedNode(null)}
827
+ onUpdateConfig={handleUpdateConfig}
828
+ />
829
+ )}
830
+ </div>
831
+ </div>
832
+ );
833
+ }
834
+
835
+ // ---------------------------------------------------------------------------
836
+ // Page wrapper (provides ReactFlow context)
837
+ // ---------------------------------------------------------------------------
838
+
839
+ export default function FlowDetailPage() {
840
+ const params = useParams();
841
+ const flowId = params.id as string;
842
+
843
+ return (
844
+ <div className="h-screen flex flex-col overflow-hidden">
845
+ <ReactFlowProvider>
846
+ <CanvasEditor flowId={flowId} />
847
+ </ReactFlowProvider>
848
  </div>
849
  );
850
  }
frontend/src/app/(dashboard)/automations/page.tsx CHANGED
@@ -4,16 +4,15 @@ import { useState, useEffect } from "react";
4
  import {
5
  Zap,
6
  Plus,
7
- Play,
8
- Pause,
9
- MoreHorizontal,
10
  ChevronRight,
11
  Loader2,
12
- FileCode
13
  } from "lucide-react";
14
  import { cn } from "@/lib/utils";
15
  import { apiClient } from "@/lib/api";
 
16
  import Link from "next/link";
 
17
 
18
  interface Flow {
19
  id: string;
@@ -24,8 +23,10 @@ interface Flow {
24
  }
25
 
26
  export default function AutomationsPage() {
 
27
  const [flows, setFlows] = useState<Flow[]>([]);
28
  const [isLoading, setIsLoading] = useState(true);
 
29
 
30
  // Fetch flows from backend
31
  useEffect(() => {
@@ -39,6 +40,15 @@ export default function AutomationsPage() {
39
  fetchFlows();
40
  }, []);
41
 
 
 
 
 
 
 
 
 
 
42
  return (
43
  <div className="p-8 max-w-7xl mx-auto space-y-8">
44
  {/* Header */}
@@ -49,13 +59,23 @@ export default function AutomationsPage() {
49
  Build and manage your AI-driven workflows.
50
  </p>
51
  </div>
52
- <Link
53
- href="/automations/new"
54
- className="flex items-center gap-2 bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg font-medium transition-colors shadow-sm"
55
- >
56
- <Plus className="w-4 h-4" />
57
- Create Flow
58
- </Link>
 
 
 
 
 
 
 
 
 
 
59
  </div>
60
 
61
  {/* Stats/Badges Placeholder */}
@@ -88,9 +108,9 @@ export default function AutomationsPage() {
88
  <h3 className="font-semibold text-lg">No automations yet</h3>
89
  <p className="text-muted-foreground">Get started by creating your first AI workflow.</p>
90
  </div>
91
- <button className="text-teal-600 font-medium hover:underline">
92
- View templates
93
- </button>
94
  </div>
95
  ) : (
96
  <div className="grid gap-3">
 
4
  import {
5
  Zap,
6
  Plus,
 
 
 
7
  ChevronRight,
8
  Loader2,
9
+ LayoutTemplate,
10
  } from "lucide-react";
11
  import { cn } from "@/lib/utils";
12
  import { apiClient } from "@/lib/api";
13
+ import { createFlow } from "@/lib/automations-api";
14
  import Link from "next/link";
15
+ import { useRouter } from "next/navigation";
16
 
17
  interface Flow {
18
  id: string;
 
23
  }
24
 
25
  export default function AutomationsPage() {
26
+ const router = useRouter();
27
  const [flows, setFlows] = useState<Flow[]>([]);
28
  const [isLoading, setIsLoading] = useState(true);
29
+ const [creating, setCreating] = useState(false);
30
 
31
  // Fetch flows from backend
32
  useEffect(() => {
 
40
  fetchFlows();
41
  }, []);
42
 
43
+ const handleCreateBlank = async () => {
44
+ setCreating(true);
45
+ const res = await createFlow("Untitled Automation");
46
+ if (res.success && res.data) {
47
+ router.push(`/automations/${res.data.flow_id}`);
48
+ }
49
+ setCreating(false);
50
+ };
51
+
52
  return (
53
  <div className="p-8 max-w-7xl mx-auto space-y-8">
54
  {/* Header */}
 
59
  Build and manage your AI-driven workflows.
60
  </p>
61
  </div>
62
+ <div className="flex items-center gap-2">
63
+ <Link
64
+ href="/templates"
65
+ className="flex items-center gap-2 border border-border hover:border-teal-400 text-foreground px-4 py-2 rounded-lg font-medium transition-colors"
66
+ >
67
+ <LayoutTemplate className="w-4 h-4" />
68
+ Templates
69
+ </Link>
70
+ <button
71
+ onClick={handleCreateBlank}
72
+ disabled={creating}
73
+ className="flex items-center gap-2 bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg font-medium transition-colors shadow-sm disabled:opacity-50"
74
+ >
75
+ {creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
76
+ Create Blank
77
+ </button>
78
+ </div>
79
  </div>
80
 
81
  {/* Stats/Badges Placeholder */}
 
108
  <h3 className="font-semibold text-lg">No automations yet</h3>
109
  <p className="text-muted-foreground">Get started by creating your first AI workflow.</p>
110
  </div>
111
+ <Link href="/templates" className="text-teal-600 font-medium hover:underline">
112
+ Browse templates
113
+ </Link>
114
  </div>
115
  ) : (
116
  <div className="grid gap-3">
frontend/src/app/(dashboard)/templates/[slug]/page.tsx ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useParams, useRouter } from "next/navigation";
5
+ import Link from "next/link";
6
+ import {
7
+ ArrowLeft,
8
+ Loader2,
9
+ Zap,
10
+ MessageSquare,
11
+ MousePointerClick,
12
+ Bot,
13
+ Send,
14
+ User,
15
+ Tag,
16
+ Database,
17
+ GitBranch,
18
+ Clock,
19
+ AlertCircle,
20
+ CheckCircle2,
21
+ } from "lucide-react";
22
+ import { cn } from "@/lib/utils";
23
+ import { getTemplate, cloneTemplate, type TemplateDetail } from "@/lib/templates-api";
24
+
25
+ const NODE_TYPE_ICONS: Record<string, React.ReactNode> = {
26
+ MESSAGE_INBOUND: <MessageSquare className="w-4 h-4" />,
27
+ LEAD_AD_SUBMIT: <MousePointerClick className="w-4 h-4" />,
28
+ AI_REPLY: <Bot className="w-4 h-4" />,
29
+ SEND_MESSAGE: <Send className="w-4 h-4" />,
30
+ HUMAN_HANDOVER: <User className="w-4 h-4" />,
31
+ TAG_CONTACT: <Tag className="w-4 h-4" />,
32
+ ZOHO_UPSERT_LEAD: <Database className="w-4 h-4" />,
33
+ CONDITION: <GitBranch className="w-4 h-4" />,
34
+ WAIT_DELAY: <Clock className="w-4 h-4" />,
35
+ };
36
+
37
+ const NODE_TYPE_LABELS: Record<string, string> = {
38
+ MESSAGE_INBOUND: "Inbound Message",
39
+ LEAD_AD_SUBMIT: "Lead Ad Submission",
40
+ AI_REPLY: "AI Reply",
41
+ SEND_MESSAGE: "Send Message",
42
+ HUMAN_HANDOVER: "Human Handover",
43
+ TAG_CONTACT: "Tag Contact",
44
+ ZOHO_UPSERT_LEAD: "Zoho: Upsert Lead",
45
+ CONDITION: "Condition (Coming Soon)",
46
+ WAIT_DELAY: "Wait / Delay (Coming Soon)",
47
+ };
48
+
49
+ const INTEGRATION_LABELS: Record<string, string> = {
50
+ zoho: "Zoho CRM",
51
+ whatsapp: "WhatsApp",
52
+ meta: "Meta (Messenger/Instagram)",
53
+ };
54
+
55
+ function FlowSummary({ graph }: { graph: Record<string, any> }) {
56
+ const nodes: any[] = graph.nodes ?? [];
57
+ const edges: any[] = graph.edges ?? [];
58
+
59
+ const triggerNode = nodes.find((n: any) => n.type === "triggerNode");
60
+ const actionNodes = nodes.filter((n: any) => n.type === "actionNode");
61
+
62
+ return (
63
+ <div className="space-y-3">
64
+ {/* Trigger */}
65
+ {triggerNode && (
66
+ <div className="flex items-center gap-3 p-3 rounded-xl border border-teal-200 dark:border-teal-800 bg-teal-50 dark:bg-teal-950/20">
67
+ <div className="p-2 rounded-lg bg-teal-600 text-white">
68
+ <Zap className="w-4 h-4" />
69
+ </div>
70
+ <div>
71
+ <p className="text-xs font-semibold text-teal-700 dark:text-teal-300 uppercase tracking-wide">
72
+ Trigger
73
+ </p>
74
+ <p className="text-sm font-medium text-foreground">
75
+ {NODE_TYPE_LABELS[triggerNode.data?.nodeType] ?? triggerNode.data?.nodeType}
76
+ </p>
77
+ </div>
78
+ </div>
79
+ )}
80
+
81
+ {/* Steps */}
82
+ {actionNodes.map((node: any, i: number) => (
83
+ <div key={node.id} className="flex items-center gap-3 p-3 rounded-xl border border-border bg-card">
84
+ <div className="w-6 h-6 rounded-full border border-border bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground shrink-0">
85
+ {i + 1}
86
+ </div>
87
+ <span className="text-muted-foreground">
88
+ {NODE_TYPE_ICONS[node.data?.nodeType] ?? <Bot className="w-4 h-4" />}
89
+ </span>
90
+ <p className="text-sm font-medium text-foreground">
91
+ {NODE_TYPE_LABELS[node.data?.nodeType] ?? node.data?.nodeType}
92
+ </p>
93
+ </div>
94
+ ))}
95
+
96
+ {actionNodes.length === 0 && (
97
+ <p className="text-sm text-muted-foreground">No action steps configured.</p>
98
+ )}
99
+
100
+ <p className="text-xs text-muted-foreground">
101
+ {edges.length} connection{edges.length !== 1 ? "s" : ""} in this flow
102
+ </p>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ export default function TemplateDetailPage() {
108
+ const params = useParams();
109
+ const slug = params.slug as string;
110
+ const router = useRouter();
111
+
112
+ const [template, setTemplate] = useState<TemplateDetail | null>(null);
113
+ const [loading, setLoading] = useState(true);
114
+ const [cloning, setCloning] = useState(false);
115
+ const [cloneSuccess, setCloneSuccess] = useState(false);
116
+ const [error, setError] = useState("");
117
+
118
+ useEffect(() => {
119
+ getTemplate(slug).then((res) => {
120
+ if (res.success && res.data) setTemplate(res.data);
121
+ else setError("Template not found.");
122
+ setLoading(false);
123
+ });
124
+ }, [slug]);
125
+
126
+ const handleClone = async () => {
127
+ setCloning(true);
128
+ const res = await cloneTemplate(slug);
129
+ if (res.success && res.data) {
130
+ setCloneSuccess(true);
131
+ setTimeout(() => router.push(res.data!.redirect_path), 1000);
132
+ } else {
133
+ setError(res.error ?? "Failed to clone template.");
134
+ }
135
+ setCloning(false);
136
+ };
137
+
138
+ if (loading) {
139
+ return (
140
+ <div className="flex items-center justify-center h-64">
141
+ <Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
142
+ </div>
143
+ );
144
+ }
145
+
146
+ if (error || !template) {
147
+ return (
148
+ <div className="p-8 max-w-4xl mx-auto space-y-4">
149
+ <Link href="/templates" className="flex items-center gap-2 text-muted-foreground hover:text-foreground text-sm">
150
+ <ArrowLeft className="w-4 h-4" /> Back to Templates
151
+ </Link>
152
+ <div className="flex items-center gap-2 text-red-600">
153
+ <AlertCircle className="w-5 h-5" />
154
+ <p>{error || "Template not found."}</p>
155
+ </div>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ const hasPublishedVersion = !!template.latest_version;
161
+
162
+ return (
163
+ <div className="p-8 max-w-5xl mx-auto space-y-8">
164
+ {/* Back */}
165
+ <Link
166
+ href="/templates"
167
+ className="flex items-center gap-2 text-muted-foreground hover:text-foreground text-sm w-fit"
168
+ >
169
+ <ArrowLeft className="w-4 h-4" />
170
+ Back to Templates
171
+ </Link>
172
+
173
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
174
+ {/* Left: Details */}
175
+ <div className="lg:col-span-2 space-y-6">
176
+ <div className="space-y-3">
177
+ <h1 className="text-3xl font-bold tracking-tight">{template.name}</h1>
178
+ {template.description && (
179
+ <p className="text-muted-foreground text-lg">{template.description}</p>
180
+ )}
181
+ <div className="flex flex-wrap gap-2">
182
+ {template.platforms.map((p) => (
183
+ <span
184
+ key={p}
185
+ className="text-xs font-semibold px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 uppercase tracking-wide"
186
+ >
187
+ {p}
188
+ </span>
189
+ ))}
190
+ {template.industry_tags.map((tag) => (
191
+ <span
192
+ key={tag}
193
+ className="text-xs font-semibold px-2 py-1 rounded-full bg-muted text-muted-foreground uppercase tracking-wide"
194
+ >
195
+ {tag}
196
+ </span>
197
+ ))}
198
+ </div>
199
+ </div>
200
+
201
+ {/* Required integrations warning */}
202
+ {template.required_integrations.length > 0 && (
203
+ <div className="flex items-start gap-2 p-4 rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/20 text-sm text-amber-700 dark:text-amber-300">
204
+ <AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
205
+ <div>
206
+ <p className="font-semibold">Required integrations:</p>
207
+ <ul className="mt-1 list-disc list-inside space-y-0.5">
208
+ {template.required_integrations.map((int) => (
209
+ <li key={int}>{INTEGRATION_LABELS[int] ?? int}</li>
210
+ ))}
211
+ </ul>
212
+ <p className="mt-2 text-xs">
213
+ Make sure these are connected in your workspace settings before using this template.
214
+ </p>
215
+ </div>
216
+ </div>
217
+ )}
218
+
219
+ {/* Flow Summary */}
220
+ <div className="space-y-3">
221
+ <h2 className="font-semibold text-lg">Flow Structure</h2>
222
+ {hasPublishedVersion ? (
223
+ <FlowSummary graph={template.latest_version!.builder_graph_json} />
224
+ ) : (
225
+ <p className="text-sm text-muted-foreground">
226
+ No published version available yet.
227
+ </p>
228
+ )}
229
+ </div>
230
+ </div>
231
+
232
+ {/* Right: CTA */}
233
+ <div className="space-y-4">
234
+ <div className="rounded-2xl border border-border bg-card p-5 shadow-sm space-y-4 sticky top-6">
235
+ <div>
236
+ <p className="text-sm font-medium text-foreground">
237
+ {hasPublishedVersion
238
+ ? `Version ${template.latest_version!.version_number}`
239
+ : "Not yet published"}
240
+ </p>
241
+ {template.latest_version?.published_at && (
242
+ <p className="text-xs text-muted-foreground">
243
+ Published {new Date(template.latest_version.published_at).toLocaleDateString()}
244
+ </p>
245
+ )}
246
+ </div>
247
+
248
+ {hasPublishedVersion ? (
249
+ <button
250
+ onClick={handleClone}
251
+ disabled={cloning || cloneSuccess}
252
+ className={cn(
253
+ "w-full flex items-center justify-center gap-2 py-2.5 rounded-xl font-medium transition-colors",
254
+ cloneSuccess
255
+ ? "bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-300"
256
+ : "bg-teal-600 hover:bg-teal-700 text-white disabled:opacity-50"
257
+ )}
258
+ >
259
+ {cloning ? (
260
+ <Loader2 className="w-4 h-4 animate-spin" />
261
+ ) : cloneSuccess ? (
262
+ <CheckCircle2 className="w-4 h-4" />
263
+ ) : (
264
+ <Zap className="w-4 h-4" />
265
+ )}
266
+ {cloneSuccess ? "Opening canvas…" : "Use This Template"}
267
+ </button>
268
+ ) : (
269
+ <div className="text-center text-sm text-muted-foreground py-2">
270
+ Not available yet
271
+ </div>
272
+ )}
273
+
274
+ {error && (
275
+ <p className="text-xs text-red-600 text-center">{error}</p>
276
+ )}
277
+
278
+ <Link
279
+ href="/templates"
280
+ className="block text-center text-sm text-muted-foreground hover:text-foreground transition-colors"
281
+ >
282
+ Browse all templates
283
+ </Link>
284
+ </div>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ );
289
+ }
frontend/src/app/(dashboard)/templates/page.tsx ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import Link from "next/link";
6
+ import {
7
+ LayoutTemplate,
8
+ Loader2,
9
+ Search,
10
+ Star,
11
+ ArrowRight,
12
+ Zap,
13
+ CheckCircle2,
14
+ } from "lucide-react";
15
+ import { cn } from "@/lib/utils";
16
+ import { listTemplates, cloneTemplate, type TemplateListItem } from "@/lib/templates-api";
17
+
18
+ const CATEGORY_LABELS: Record<string, string> = {
19
+ lead_generation: "Lead Generation",
20
+ customer_support: "Customer Support",
21
+ sales: "Sales",
22
+ onboarding: "Onboarding",
23
+ general: "General",
24
+ };
25
+
26
+ const PLATFORM_LABELS: Record<string, string> = {
27
+ whatsapp: "WhatsApp",
28
+ meta: "Meta",
29
+ };
30
+
31
+ function TemplateBadge({ text, color = "default" }: { text: string; color?: "teal" | "blue" | "default" }) {
32
+ return (
33
+ <span
34
+ className={cn(
35
+ "text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full",
36
+ color === "teal" && "bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-300",
37
+ color === "blue" && "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300",
38
+ color === "default" && "bg-muted text-muted-foreground"
39
+ )}
40
+ >
41
+ {text}
42
+ </span>
43
+ );
44
+ }
45
+
46
+ function TemplateCard({ template }: { template: TemplateListItem }) {
47
+ const router = useRouter();
48
+ const [cloning, setCloning] = useState(false);
49
+ const [cloned, setCloned] = useState(false);
50
+
51
+ const handleUse = async () => {
52
+ setCloning(true);
53
+ const res = await cloneTemplate(template.slug);
54
+ if (res.success && res.data) {
55
+ setCloned(true);
56
+ setTimeout(() => router.push(res.data!.redirect_path), 800);
57
+ }
58
+ setCloning(false);
59
+ };
60
+
61
+ return (
62
+ <div className="flex flex-col rounded-2xl border border-border bg-card shadow-sm hover:shadow-md hover:border-teal-200 transition-all overflow-hidden group">
63
+ {/* Featured badge */}
64
+ {template.is_featured && (
65
+ <div className="flex items-center gap-1 px-4 py-2 bg-amber-50 dark:bg-amber-950/30 border-b border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300 text-xs font-semibold">
66
+ <Star className="w-3 h-3 fill-current" />
67
+ Featured
68
+ </div>
69
+ )}
70
+
71
+ <div className="flex flex-col flex-1 p-5 space-y-3">
72
+ {/* Header */}
73
+ <div className="space-y-1.5">
74
+ <div className="flex items-start justify-between gap-2">
75
+ <h3 className="font-semibold text-foreground leading-tight">{template.name}</h3>
76
+ </div>
77
+ {template.description && (
78
+ <p className="text-sm text-muted-foreground line-clamp-2">{template.description}</p>
79
+ )}
80
+ </div>
81
+
82
+ {/* Tags */}
83
+ <div className="flex flex-wrap gap-1.5">
84
+ {template.category && (
85
+ <TemplateBadge
86
+ text={CATEGORY_LABELS[template.category] ?? template.category}
87
+ color="teal"
88
+ />
89
+ )}
90
+ {template.platforms.map((p) => (
91
+ <TemplateBadge key={p} text={PLATFORM_LABELS[p] ?? p} color="blue" />
92
+ ))}
93
+ {template.industry_tags.slice(0, 2).map((tag) => (
94
+ <TemplateBadge key={tag} text={tag} />
95
+ ))}
96
+ </div>
97
+
98
+ {template.required_integrations.length > 0 && (
99
+ <p className="text-xs text-muted-foreground">
100
+ Requires: {template.required_integrations.join(", ")}
101
+ </p>
102
+ )}
103
+ </div>
104
+
105
+ {/* Footer */}
106
+ <div className="flex items-center gap-2 px-5 py-3 border-t border-border bg-muted/30">
107
+ <Link
108
+ href={`/templates/${template.slug}`}
109
+ className="flex-1 text-center text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
110
+ >
111
+ Preview
112
+ </Link>
113
+ <button
114
+ onClick={handleUse}
115
+ disabled={cloning || cloned}
116
+ className={cn(
117
+ "flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg text-sm font-medium transition-colors",
118
+ cloned
119
+ ? "bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-300"
120
+ : "bg-teal-600 hover:bg-teal-700 text-white disabled:opacity-50"
121
+ )}
122
+ >
123
+ {cloning ? (
124
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
125
+ ) : cloned ? (
126
+ <CheckCircle2 className="w-3.5 h-3.5" />
127
+ ) : (
128
+ <ArrowRight className="w-3.5 h-3.5" />
129
+ )}
130
+ {cloned ? "Opening…" : "Use Template"}
131
+ </button>
132
+ </div>
133
+ </div>
134
+ );
135
+ }
136
+
137
+ export default function TemplatesPage() {
138
+ const [templates, setTemplates] = useState<TemplateListItem[]>([]);
139
+ const [loading, setLoading] = useState(true);
140
+ const [search, setSearch] = useState("");
141
+ const [categoryFilter, setCategoryFilter] = useState("");
142
+
143
+ useEffect(() => {
144
+ listTemplates().then((res) => {
145
+ if (res.success && res.data) setTemplates(res.data);
146
+ setLoading(false);
147
+ });
148
+ }, []);
149
+
150
+ const featured = templates.filter((t) => t.is_featured);
151
+ const filtered = templates.filter((t) => {
152
+ const matchSearch =
153
+ !search ||
154
+ t.name.toLowerCase().includes(search.toLowerCase()) ||
155
+ (t.description ?? "").toLowerCase().includes(search.toLowerCase());
156
+ const matchCat = !categoryFilter || t.category === categoryFilter;
157
+ return matchSearch && matchCat;
158
+ });
159
+
160
+ const categories = Array.from(new Set(templates.map((t) => t.category).filter(Boolean)));
161
+
162
+ return (
163
+ <div className="p-8 max-w-7xl mx-auto space-y-8">
164
+ {/* Header */}
165
+ <div className="space-y-1">
166
+ <div className="flex items-center gap-2">
167
+ <LayoutTemplate className="w-6 h-6 text-teal-600" />
168
+ <h1 className="text-3xl font-bold tracking-tight">Templates</h1>
169
+ </div>
170
+ <p className="text-muted-foreground">
171
+ Pre-built automation flows. Clone one to get started instantly.
172
+ </p>
173
+ </div>
174
+
175
+ {/* Filters */}
176
+ <div className="flex items-center gap-3 flex-wrap">
177
+ <div className="relative flex-1 max-w-xs">
178
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
179
+ <input
180
+ type="text"
181
+ placeholder="Search templates…"
182
+ className="w-full pl-9 pr-3 py-2 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
183
+ value={search}
184
+ onChange={(e) => setSearch(e.target.value)}
185
+ />
186
+ </div>
187
+ <div className="flex items-center gap-2 flex-wrap">
188
+ <button
189
+ onClick={() => setCategoryFilter("")}
190
+ className={cn(
191
+ "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors",
192
+ !categoryFilter
193
+ ? "bg-teal-600 text-white border-teal-600"
194
+ : "border-border hover:border-teal-400 text-foreground"
195
+ )}
196
+ >
197
+ All
198
+ </button>
199
+ {categories.map((cat) => (
200
+ <button
201
+ key={cat}
202
+ onClick={() => setCategoryFilter(cat === categoryFilter ? "" : cat)}
203
+ className={cn(
204
+ "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors",
205
+ categoryFilter === cat
206
+ ? "bg-teal-600 text-white border-teal-600"
207
+ : "border-border hover:border-teal-400 text-foreground"
208
+ )}
209
+ >
210
+ {CATEGORY_LABELS[cat] ?? cat}
211
+ </button>
212
+ ))}
213
+ </div>
214
+ </div>
215
+
216
+ {loading ? (
217
+ <div className="flex items-center justify-center py-20">
218
+ <Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
219
+ </div>
220
+ ) : templates.length === 0 ? (
221
+ <div className="flex flex-col items-center justify-center py-20 border-2 border-dashed border-border rounded-2xl text-center space-y-3">
222
+ <Zap className="w-12 h-12 text-muted-foreground/30" />
223
+ <div>
224
+ <p className="font-semibold">No templates yet</p>
225
+ <p className="text-sm text-muted-foreground">
226
+ Check back soon — templates are published by your account admin.
227
+ </p>
228
+ </div>
229
+ </div>
230
+ ) : (
231
+ <div className="space-y-8">
232
+ {/* Featured section */}
233
+ {featured.length > 0 && !search && !categoryFilter && (
234
+ <div className="space-y-4">
235
+ <h2 className="text-lg font-semibold flex items-center gap-2">
236
+ <Star className="w-4 h-4 text-amber-500 fill-current" />
237
+ Featured
238
+ </h2>
239
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
240
+ {featured.map((t) => (
241
+ <TemplateCard key={t.id} template={t} />
242
+ ))}
243
+ </div>
244
+ </div>
245
+ )}
246
+
247
+ {/* All templates */}
248
+ <div className="space-y-4">
249
+ {(search || categoryFilter) ? null : (
250
+ <h2 className="text-lg font-semibold">All Templates</h2>
251
+ )}
252
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
253
+ {filtered.map((t) => (
254
+ <TemplateCard key={t.id} template={t} />
255
+ ))}
256
+ </div>
257
+ {filtered.length === 0 && (
258
+ <div className="text-center py-12 text-muted-foreground">
259
+ No templates match your search.
260
+ </div>
261
+ )}
262
+ </div>
263
+ </div>
264
+ )}
265
+ </div>
266
+ );
267
+ }
frontend/src/components/AdminSidebar.tsx CHANGED
@@ -19,6 +19,7 @@ import {
19
  CreditCard,
20
  Landmark,
21
  SlidersHorizontal,
 
22
  } from "lucide-react";
23
  import { cn } from "@/lib/utils";
24
  import { adminAuth } from "@/lib/admin-auth";
@@ -34,6 +35,7 @@ const adminNavItems = [
34
  { name: "Email Logs", href: "/admin/email-logs", icon: Mail },
35
  { name: "Dispatch", href: "/admin/dispatch", icon: Send },
36
  { name: "Automations", href: "/admin/automations", icon: Zap },
 
37
  { name: "Prompt Configs",href: "/admin/prompt-configs",icon: FileText },
38
  { name: "Zoho Health", href: "/admin/zoho-health", icon: Database },
39
  { name: "Monitoring", href: "/admin/monitoring", icon: Activity },
 
19
  CreditCard,
20
  Landmark,
21
  SlidersHorizontal,
22
+ LayoutTemplate,
23
  } from "lucide-react";
24
  import { cn } from "@/lib/utils";
25
  import { adminAuth } from "@/lib/admin-auth";
 
35
  { name: "Email Logs", href: "/admin/email-logs", icon: Mail },
36
  { name: "Dispatch", href: "/admin/dispatch", icon: Send },
37
  { name: "Automations", href: "/admin/automations", icon: Zap },
38
+ { name: "Templates", href: "/admin/templates", icon: LayoutTemplate },
39
  { name: "Prompt Configs",href: "/admin/prompt-configs",icon: FileText },
40
  { name: "Zoho Health", href: "/admin/zoho-health", icon: Database },
41
  { name: "Monitoring", href: "/admin/monitoring", icon: Activity },
frontend/src/components/Sidebar.tsx CHANGED
@@ -17,6 +17,7 @@ import {
17
  SendHorizontal,
18
  Inbox,
19
  Landmark,
 
20
  } from "lucide-react";
21
  import { cn } from "@/lib/utils";
22
  import { apiClient } from "@/lib/api";
@@ -26,6 +27,7 @@ const navItems = [
26
  { name: "Inbox", href: "/inbox", icon: Inbox },
27
  { name: "Contacts", href: "/contacts", icon: Users },
28
  { name: "Automations", href: "/automations", icon: Zap },
 
29
  { name: "Outbound Queue", href: "/dispatch", icon: SendHorizontal },
30
  { name: "Prompt Studio", href: "/prompt-studio", icon: Sparkles },
31
  { name: "Test Chat", href: "/test-chat", icon: MessageSquare },
 
17
  SendHorizontal,
18
  Inbox,
19
  Landmark,
20
+ LayoutTemplate,
21
  } from "lucide-react";
22
  import { cn } from "@/lib/utils";
23
  import { apiClient } from "@/lib/api";
 
27
  { name: "Inbox", href: "/inbox", icon: Inbox },
28
  { name: "Contacts", href: "/contacts", icon: Users },
29
  { name: "Automations", href: "/automations", icon: Zap },
30
+ { name: "Templates", href: "/templates", icon: LayoutTemplate },
31
  { name: "Outbound Queue", href: "/dispatch", icon: SendHorizontal },
32
  { name: "Prompt Studio", href: "/prompt-studio", icon: Sparkles },
33
  { name: "Test Chat", href: "/test-chat", icon: MessageSquare },
frontend/src/lib/admin-api.ts CHANGED
@@ -361,3 +361,93 @@ export async function patchSystemSettings(settings: Record<string, any>) {
361
  body: JSON.stringify({ settings }),
362
  });
363
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  body: JSON.stringify({ settings }),
362
  });
363
  }
364
+
365
+ // ---- Template Catalog Admin (Mission 27) -----------------------------------
366
+
367
+ export interface AdminTemplateItem {
368
+ id: string;
369
+ slug: string;
370
+ name: string;
371
+ description: string | null;
372
+ category: string;
373
+ industry_tags: string[];
374
+ platforms: string[];
375
+ required_integrations: string[];
376
+ is_featured: boolean;
377
+ is_active: boolean;
378
+ created_at: string;
379
+ }
380
+
381
+ export interface AdminTemplateVersionItem {
382
+ id: string;
383
+ version_number: number;
384
+ changelog: string | null;
385
+ is_published: boolean;
386
+ published_at: string | null;
387
+ created_at: string;
388
+ }
389
+
390
+ export async function getAdminTemplates(skip = 0, limit = 50) {
391
+ return adminRequest<{ items: AdminTemplateItem[]; total: number }>(
392
+ `/admin/templates?skip=${skip}&limit=${limit}`
393
+ );
394
+ }
395
+
396
+ export async function createAdminTemplate(payload: {
397
+ slug: string;
398
+ name: string;
399
+ description?: string;
400
+ category?: string;
401
+ industry_tags?: string[];
402
+ platforms?: string[];
403
+ required_integrations?: string[];
404
+ is_featured?: boolean;
405
+ }) {
406
+ return adminRequest<{ id: string; slug: string; name: string }>("/admin/templates", {
407
+ method: "POST",
408
+ body: JSON.stringify(payload),
409
+ });
410
+ }
411
+
412
+ export async function patchAdminTemplate(
413
+ templateId: string,
414
+ payload: {
415
+ name?: string;
416
+ description?: string;
417
+ category?: string;
418
+ is_featured?: boolean;
419
+ is_active?: boolean;
420
+ industry_tags?: string[];
421
+ platforms?: string[];
422
+ required_integrations?: string[];
423
+ }
424
+ ) {
425
+ return adminRequest<{ id: string; updated: boolean }>(`/admin/templates/${templateId}`, {
426
+ method: "PATCH",
427
+ body: JSON.stringify(payload),
428
+ });
429
+ }
430
+
431
+ export async function createTemplateVersion(
432
+ templateId: string,
433
+ payload: { builder_graph_json: Record<string, any>; changelog?: string }
434
+ ) {
435
+ return adminRequest<{ valid: boolean; id?: string; version_number?: number; errors?: any[] }>(
436
+ `/admin/templates/${templateId}/versions`,
437
+ {
438
+ method: "POST",
439
+ body: JSON.stringify(payload),
440
+ }
441
+ );
442
+ }
443
+
444
+ export async function publishTemplateVersion(templateId: string) {
445
+ return adminRequest<{ published: boolean; version_number: number; published_at: string }>(
446
+ `/admin/templates/${templateId}/publish`,
447
+ { method: "POST" }
448
+ );
449
+ }
450
+
451
+ export async function getTemplateVersions(templateId: string) {
452
+ return adminRequest<AdminTemplateVersionItem[]>(`/admin/templates/${templateId}/versions`);
453
+ }
frontend/src/lib/automations-api.ts ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Typed API wrappers for the Automation Builder v2 endpoints (Mission 27).
3
+ * Uses apiClient from api.ts — injects product JWT + X-Workspace-ID automatically.
4
+ */
5
+ import { apiClient } from "./api";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Types
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export interface BuilderNode {
12
+ id: string;
13
+ type: "triggerNode" | "actionNode";
14
+ position: { x: number; y: number };
15
+ data: {
16
+ nodeType: string;
17
+ platform?: string;
18
+ config: Record<string, any>;
19
+ hasError?: boolean;
20
+ };
21
+ }
22
+
23
+ export interface BuilderEdge {
24
+ id: string;
25
+ source: string;
26
+ target: string;
27
+ sourceHandle?: string | null;
28
+ }
29
+
30
+ export interface BuilderGraph {
31
+ nodes: BuilderNode[];
32
+ edges: BuilderEdge[];
33
+ }
34
+
35
+ export interface ValidationError {
36
+ node_id: string;
37
+ field: string;
38
+ message: string;
39
+ }
40
+
41
+ export interface FlowDraft {
42
+ builder_graph_json: BuilderGraph | null;
43
+ last_validation_errors: { errors: ValidationError[]; validated_at: string } | null;
44
+ updated_at: string | null;
45
+ }
46
+
47
+ export interface FlowVersion {
48
+ id: string;
49
+ version_number: number;
50
+ is_published: boolean;
51
+ is_active: boolean;
52
+ created_at: string;
53
+ }
54
+
55
+ export interface SimulateStep {
56
+ node_id: string;
57
+ node_type: string;
58
+ description: string;
59
+ dispatch_blocked: boolean;
60
+ would_send?: string;
61
+ would_dispatch?: boolean;
62
+ mock_payload?: Record<string, any>;
63
+ }
64
+
65
+ export interface SimulateResult {
66
+ valid: boolean;
67
+ steps: SimulateStep[];
68
+ dispatch_blocked: boolean;
69
+ message: string;
70
+ errors?: ValidationError[];
71
+ }
72
+
73
+ export interface Flow {
74
+ id: string;
75
+ name: string;
76
+ description: string;
77
+ status: "draft" | "published" | "archived";
78
+ published_version_id: string | null;
79
+ created_at: string;
80
+ updated_at: string;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Flow CRUD
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export function createFlow(name: string, description?: string) {
88
+ return apiClient.post<{ flow_id: string }>("/automations", { name, description });
89
+ }
90
+
91
+ export function listFlows() {
92
+ return apiClient.get<Flow[]>("/automations");
93
+ }
94
+
95
+ export function getFlow(flowId: string) {
96
+ return apiClient.get<Flow & { definition: any; version_number: number | null }>(
97
+ `/automations/${flowId}`
98
+ );
99
+ }
100
+
101
+ export function updateFlow(flowId: string, payload: { name?: string; description?: string }) {
102
+ return apiClient.patch<{ id: string; name: string }>(`/automations/${flowId}`, payload);
103
+ }
104
+
105
+ export function deleteFlow(flowId: string) {
106
+ return apiClient.delete<{ deleted: boolean }>(`/automations/${flowId}`);
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Draft
111
+ // ---------------------------------------------------------------------------
112
+
113
+ export function getDraft(flowId: string) {
114
+ return apiClient.get<FlowDraft>(`/automations/${flowId}/draft`);
115
+ }
116
+
117
+ export function saveDraft(flowId: string, graph: BuilderGraph) {
118
+ return apiClient.put<{ saved: boolean; updated_at: string }>(`/automations/${flowId}/draft`, {
119
+ builder_graph_json: graph,
120
+ });
121
+ }
122
+
123
+ export function validateDraft(flowId: string) {
124
+ return apiClient.post<{ valid: boolean; errors: ValidationError[] }>(
125
+ `/automations/${flowId}/draft/validate`
126
+ );
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Publish / Versions / Rollback
131
+ // ---------------------------------------------------------------------------
132
+
133
+ export function publishDraft(flowId: string) {
134
+ return apiClient.post<{
135
+ success: boolean;
136
+ published: boolean;
137
+ version_id?: string;
138
+ version_number?: number;
139
+ published_at?: string;
140
+ errors?: ValidationError[];
141
+ }>(`/automations/${flowId}/publish`);
142
+ }
143
+
144
+ export function getVersions(flowId: string) {
145
+ return apiClient.get<FlowVersion[]>(`/automations/${flowId}/versions`);
146
+ }
147
+
148
+ export function rollbackToVersion(flowId: string, versionId: string) {
149
+ return apiClient.post<{
150
+ new_version_id: string;
151
+ new_version_number: number;
152
+ rolled_back_to: number;
153
+ }>(`/automations/${flowId}/rollback/${versionId}`);
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Simulate
158
+ // ---------------------------------------------------------------------------
159
+
160
+ export function simulate(flowId: string, mockPayload?: Record<string, any>) {
161
+ return apiClient.post<SimulateResult>(`/automations/${flowId}/simulate`, {
162
+ mock_payload: mockPayload ?? null,
163
+ });
164
+ }
frontend/src/lib/templates-api.ts ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Typed API wrappers for the Template Catalog workspace endpoints (Mission 27).
3
+ * Uses apiClient from api.ts — injects product JWT + X-Workspace-ID automatically.
4
+ */
5
+ import { apiClient } from "./api";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Types
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export interface TemplateListItem {
12
+ id: string;
13
+ slug: string;
14
+ name: string;
15
+ description: string | null;
16
+ category: string;
17
+ industry_tags: string[];
18
+ platforms: string[];
19
+ required_integrations: string[];
20
+ is_featured: boolean;
21
+ }
22
+
23
+ export interface TemplateVersion {
24
+ id: string;
25
+ version_number: number;
26
+ builder_graph_json: Record<string, any>;
27
+ changelog: string | null;
28
+ published_at: string | null;
29
+ }
30
+
31
+ export interface TemplateDetail extends TemplateListItem {
32
+ latest_version: TemplateVersion | null;
33
+ }
34
+
35
+ export interface TemplateFilters {
36
+ category?: string;
37
+ platform?: string;
38
+ featured?: boolean;
39
+ skip?: number;
40
+ limit?: number;
41
+ }
42
+
43
+ export interface CloneResult {
44
+ flow_id: string;
45
+ flow_name: string;
46
+ redirect_path: string;
47
+ template_slug: string;
48
+ required_integrations: string[];
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Template Catalog API
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export function listTemplates(filters: TemplateFilters = {}) {
56
+ const params = new URLSearchParams();
57
+ if (filters.category) params.set("category", filters.category);
58
+ if (filters.platform) params.set("platform", filters.platform);
59
+ if (filters.featured !== undefined) params.set("featured", String(filters.featured));
60
+ if (filters.skip !== undefined) params.set("skip", String(filters.skip));
61
+ if (filters.limit !== undefined) params.set("limit", String(filters.limit));
62
+
63
+ const query = params.toString() ? `?${params.toString()}` : "";
64
+ return apiClient.get<TemplateListItem[]>(`/templates${query}`);
65
+ }
66
+
67
+ export function getTemplate(slug: string) {
68
+ return apiClient.get<TemplateDetail>(`/templates/${slug}`);
69
+ }
70
+
71
+ export function cloneTemplate(slug: string, name?: string) {
72
+ return apiClient.post<CloneResult>(`/templates/${slug}/clone`, { name: name || "" });
73
+ }