Spaces:
Running
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>
- backend/alembic/versions/2163b9b16da8_merge_m20_m27_heads.py +28 -0
- backend/alembic/versions/g7h8i9j0k1l2_mission_27_builder_v2.py +152 -0
- backend/app/api/v1/admin.py +296 -1
- backend/app/api/v1/automations.py +512 -53
- backend/app/api/v1/templates.py +229 -0
- backend/app/core/catalog_registry.py +27 -1
- backend/app/domain/builder_translator.py +316 -0
- backend/app/models/models.py +60 -1
- backend/app/workers/tasks.py +22 -6
- backend/main.py +2 -1
- backend/tests/test_automation.py +469 -26
- backend/tests/test_builder_translator.py +331 -0
- backend/tests/test_templates.py +536 -0
- frontend/package-lock.json +233 -2
- frontend/package.json +1 -0
- frontend/src/app/(admin)/admin/templates/[id]/page.tsx +356 -0
- frontend/src/app/(admin)/admin/templates/page.tsx +228 -0
- frontend/src/app/(dashboard)/automations/[id]/NodeConfigPanel.tsx +323 -0
- frontend/src/app/(dashboard)/automations/[id]/NodePalette.tsx +182 -0
- frontend/src/app/(dashboard)/automations/[id]/nodes/ActionNode.tsx +168 -0
- frontend/src/app/(dashboard)/automations/[id]/nodes/TriggerNode.tsx +68 -0
- frontend/src/app/(dashboard)/automations/[id]/page.tsx +812 -184
- frontend/src/app/(dashboard)/automations/page.tsx +34 -14
- frontend/src/app/(dashboard)/templates/[slug]/page.tsx +289 -0
- frontend/src/app/(dashboard)/templates/page.tsx +267 -0
- frontend/src/components/AdminSidebar.tsx +2 -0
- frontend/src/components/Sidebar.tsx +2 -0
- frontend/src/lib/admin-api.ts +90 -0
- frontend/src/lib/automations-api.ts +164 -0
- frontend/src/lib/templates-api.ts +73 -0
|
@@ -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
|
|
@@ -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")
|
|
@@ -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 |
+
])
|
|
@@ -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
|
| 12 |
-
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
| 40 |
):
|
| 41 |
"""List all flows for the workspace."""
|
| 42 |
-
result = await db.execute(
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 142 |
):
|
| 143 |
-
"""Get flow
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
flow.status = FlowStatus.PUBLISHED
|
| 189 |
-
|
| 190 |
-
db.add(last_version)
|
| 191 |
db.add(flow)
|
|
|
|
| 192 |
await audit_event(
|
| 193 |
-
db, action="
|
| 194 |
entity_id=str(flow.id), actor_user_id=current_user.id,
|
| 195 |
-
outcome="success", workspace_id=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
)
|
| 197 |
await db.commit()
|
| 198 |
|
| 199 |
-
return wrap_data({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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})
|
|
@@ -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 |
+
})
|
|
@@ -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 |
-
|
|
|
|
| 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 ─────────────────────────────────────────
|
|
@@ -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
|
|
@@ -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 |
+
)
|
|
@@ -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 |
-
|
|
|
|
| 160 |
Flow.workspace_id == event.workspace_id,
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
if flow_version:
|
| 167 |
# 4. Create Execution Instance
|
| 168 |
nodes = flow_version.definition_json.get("nodes", [])
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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"])
|
|
@@ -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": "
|
| 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
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
"name": "
|
| 21 |
-
"description": "
|
| 22 |
-
"steps": [],
|
| 23 |
"trigger": {"type": "MESSAGE_INBOUND", "platform": "WHATSAPP", "keywords": []},
|
| 24 |
-
"publish":
|
| 25 |
}
|
| 26 |
-
|
| 27 |
-
assert
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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")
|
|
@@ -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
|
|
@@ -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 |
-
"
|
| 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 |
-
"
|
| 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 |
}
|
|
@@ -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",
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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);
|
|
@@ -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);
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
} from "lucide-react";
|
| 15 |
import Link from "next/link";
|
| 16 |
-
import { useParams } from "next/navigation";
|
| 17 |
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 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 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
try {
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
} finally {
|
| 54 |
-
setIsPublishing(false);
|
| 55 |
}
|
|
|
|
|
|
|
|
|
|
| 56 |
};
|
| 57 |
|
| 58 |
-
|
| 59 |
-
<div className="
|
| 60 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
</div>
|
| 62 |
);
|
|
|
|
| 63 |
|
| 64 |
-
|
|
|
|
|
|
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
return (
|
| 72 |
-
<div className="
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 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 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 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 |
-
|
| 119 |
-
|
| 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 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
</
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
| 162 |
</div>
|
| 163 |
-
|
| 164 |
-
<
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
</div>
|
| 167 |
))}
|
| 168 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
</div>
|
|
|
|
| 189 |
</div>
|
| 190 |
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
<
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
</
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 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 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
</div>
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: “{step.would_send}”
|
| 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 |
}
|
|
@@ -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 |
-
|
| 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 |
-
<
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 92 |
-
|
| 93 |
-
</
|
| 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">
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 },
|
|
@@ -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 },
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|
|
@@ -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 |
+
}
|