Changeset 0.31.2 (#205)

This commit is contained in:
2026-03-19 13:29:27 +00:00
parent 8364440081
commit 6668e546fe
25 changed files with 1357 additions and 50 deletions

View File

@@ -1,5 +1,46 @@
# Changelog
## [0.31.2] — 2026-03-19
### Summary
Team Workflow Self-Service: team admins can now create, edit, publish,
and delete workflows scoped to their team without requiring platform
admin access. New `/teams/:teamId/workflows` route group with full
CRUD, stage management, and publish. New "Workflows" tab in the
team admin panel with visual stage builder. Team slugs enable clean
public workflow URLs (`/w/engineering/intake` instead of UUID).
### New
- **Team-scoped workflow routes** — full CRUD under
`/teams/:teamId/workflows` behind `RequireTeamAdmin`, with ownership
guards verifying `workflow.team_id` matches the path parameter.
Stage CRUD, reorder, publish, and version read all team-scoped.
- **Team admin Workflows tab** — 9th tab in the team admin modal.
List, create, edit, delete workflows. Visual stage builder with
persona picker (team personas), form builder, drag-and-drop reorder.
Publish with version display.
- **Team slugs** — `slug` column on teams table, auto-generated from
name on create/update. Workflow public URLs now use team slug
(`/w/engineering/intake`) instead of UUID. `GetBySlug` store method
with fallback resolution in workflow entry + page renderer.
- **Team-scoped workflow API methods** — `API.listTeamWorkflows()`,
`createTeamWorkflow()`, `updateTeamWorkflow()`, etc. in workflow-api.js.
- **`force_team_id` injection** — `WorkflowHandler.Create` now checks
for `force_team_id` context key (same pattern as `CreateTeamTask`).
### Fixed
- **Auth rate limiter** — burst lowered from 8 to 5 to ensure rate
limiting triggers within 10 rapid sequential requests.
- **Package settings assertions** — ICD runner now checks
`response.values` instead of top-level keys (matches handler envelope).
- **Provider SSE parser** — ICD runner now captures `reasoning_content`
deltas (DeepSeek/reasoning models) in addition to `content` deltas.
- **Team workflow version test** — captures `version_number` from
publish response instead of hardcoding `1`.
## [0.31.1] — 2026-03-18
### Summary

View File

@@ -1 +1 @@
0.31.1
0.31.2

View File

@@ -41,9 +41,11 @@ v0.9.xv0.28.7 Foundation through Platform Polish ✅
v0.31.1 SDK Exercise ✅ │
v0.31.2 Team Workflows │
│ │
│ v0.35.0 Workflow Product
│ │
══════╪═══════════════════════════════╪══════
│ MVP v0.50.0 │
│ (v0.31.2 + v0.34.0 │
│ (v0.31.2 + v0.35.0 │
│ + mobile + deployment docs) │
══════╪═══════════════════════════════╪══════
@@ -309,29 +311,34 @@ Depends on: v0.31.0.
- [x] Layout-only CSS — zero references to SDK component classes
- [x] E2E tests: install, settings, export/import round-trip (7 tests)
### v0.31.2 — Team Workflow Self-Service
### v0.31.2 — Team Workflow Self-Service
Close the gap: team admins can create and manage workflows for their
team without requiring platform admin access. Backend routes exist
(`/workflows` with `team_id`), but no team-scoped routes or UI.
team without requiring platform admin access. Clean public URLs via
team slugs (`/w/engineering/intake`).
Depends on: v0.31.1.
**CS0 — Backend: Team-Scoped Workflow Routes:**
- [ ] `/teams/:teamId/workflows` route group behind `RequireTeamAdmin`
- [ ] CRUD: GET (list), POST (create, inject team_id), PATCH (update), DELETE
- [ ] Stage CRUD: GET, POST, PUT, DELETE, PATCH reorder — scoped to team workflows
- [ ] Publish: `POST /teams/:teamId/workflows/:id/publish`
- [ ] Ownership guard: all mutating ops verify `workflow.team_id == :teamId`
- [ ] Reuse existing `WorkflowHandler` methods — team ID injection, not new logic
**CS0 — Backend: Team-Scoped Workflow Routes + Team Slugs:**
- [x] `/teams/:teamId/workflows` route group behind `RequireTeamAdmin`
- [x] CRUD: GET (list), POST (create, inject team_id), PATCH (update), DELETE
- [x] Stage CRUD: GET, POST, PUT, DELETE, PATCH reorder — scoped to team workflows
- [x] Publish: `POST /teams/:teamId/workflows/:id/publish`
- [x] Ownership guard: all mutating ops verify `workflow.team_id == :teamId`
- [x] Reuse existing `WorkflowHandler` methods — team ID injection, not new logic
- [x] Team `slug` column — auto-generated from name, UNIQUE, `GetBySlug` store method
- [x] Workflow entry + page renderer resolve scope via team slug (UUID fallback)
- [x] Auth rate limiter burst 8→5
**CS1 — Frontend: Workflows Tab in Team Admin:**
- [ ] 9th tab "Workflows" in team admin modal
- [ ] List team workflows with status badge (active/inactive)
- [ ] Create/edit workflow form (name, slug, entry_mode, branding, retention)
- [ ] Stage builder (add/reorder/delete stages, persona picker, history_mode)
- [ ] Publish button with version display
- [ ] Adapted from platform admin workflow UI (`_loadAdminWorkflows` reference)
- [x] 9th tab "Workflows" in team admin modal
- [x] List team workflows with status badge (active/inactive)
- [x] Create/edit workflow form (name, slug, entry_mode, branding, retention)
- [x] Stage builder (add/reorder/delete stages, persona picker, history_mode)
- [x] Publish button with version display
- [x] Adapted from platform admin workflow UI (`_loadAdminWorkflows` reference)
- [x] Public URL display uses team slug (`/w/engineering/intake`)
- [x] ICD runner: team workflow CRUD tests, package settings fix, SSE reasoning_content fix
**Future (not blocking):**
- Team admin modal → full surface package (when tab count justifies it)
@@ -411,6 +418,64 @@ Depends on: v0.29.2 (DB extensions — extension data in exports).
- [ ] Backup/restore CronJob manifests for K8s
- [ ] Admin export: team/user config (excludes vault-encrypted keys)
### v0.35.0 — Workflow Product
Bridge from workflow infrastructure to real business process automation.
Visitors see forms (not just chat), collected data flows through Starlark
enrichment, stages branch conditionally, and team members review
structured data — not chat transcripts.
Depends on: v0.31.2 (team workflow self-service), v0.29.0 (Starlark sandbox).
**Form Rendering Surface:**
- [ ] Visitor form renderer — reads `form_template` from stage, renders
HTML inputs (text, email, select, date, textarea, checkbox, file),
validates client-side, submits via `/w/:id/form-submit`
- [ ] Progressive form — multi-step within a single stage (fieldsets)
- [ ] Conditional fields — show/hide based on previous answers
- [ ] File upload in forms — attach to stage_data via storage API
- [ ] Branded form page — uses workflow `branding` (colors, logo, tagline)
**Data Pipeline (between-stage processing):**
- [ ] `on_advance` hook — Starlark entry point fires after each stage
transition, receives `stage_data`, can enrich/transform/reject
- [ ] External data enrichment — Starlark `http.fetch` pulls from
external APIs, merges results into `stage_data` (reduce double entry)
- [ ] Data validation rules — Starlark `on_validate` can enforce
cross-field business rules beyond per-field type checks
- [ ] Stage data schema — typed `stage_data` with declared fields,
not opaque JSON blob. Enables structured review views
**Conditional Routing:**
- [ ] Branch expressions — `transition_rules.condition` evaluated against
`stage_data` to select next stage (not always ordinal+1)
- [ ] AI-triggered routing — persona calls `workflow_route` tool with
a target stage name based on conversation analysis
- [ ] Escalation pattern — "if AI confidence < threshold, route to
human review stage" (help desk use case)
- [ ] Loop stages — return to a previous stage for correction without
using reject (iterative data gathering)
**Structured Review:**
- [ ] Assignment review view — team member sees `stage_data` as a
structured card/form, not just chat history
- [ ] Approval/reject with comments — reviewer adds notes visible
to the next stage (not buried in chat)
- [ ] Side-by-side view — chat history + structured data together
- [ ] Bulk review — multiple assignments in a queue with keyboard nav
**Monitoring Dashboard:**
- [ ] Active instances list — workflow name, current stage, assignee,
age, last activity
- [ ] Stage funnel — how many instances at each stage, bottleneck detection
- [ ] SLA timers — configurable per-stage, visible in review + dashboard
- [ ] Stale instance alerts — notify team admins when instances age out
**Use case validation targets:**
- Help desk: AI + KB → escalation to human → resolution tracking
- Data intake: form → Starlark enrichment → human review → follow-up
- Onboarding: multi-stage form → conditional branching → team assignment
---
## MVP v0.50.0
@@ -424,6 +489,8 @@ reading Go source code. Team admins build workflows visually.
workflow builder, SDK-based surfaces, team workflow self-service)
- Operations track through v0.34.0 (multi-replica HA, observability,
data portability)
- Workflow product v0.35.0 (form rendering, data pipeline, conditional
routing, structured review, monitoring dashboard)
**Additionally requires:**
- [ ] Deployment guide: step-by-step for IT team (PG cluster,

View File

@@ -86,8 +86,8 @@
// Verify persistence
var check = await T.apiGet('/admin/packages/' + dashPkgId + '/settings');
T.assert(check.refresh_interval === 30 || check.data?.refresh_interval === 30,
'refresh_interval should persist as 30');
var vals = check.values || {};
T.assert(vals.refresh_interval === 30, 'refresh_interval should persist as 30');
});
// ── Export/Import Round-Trip ──

View File

@@ -99,8 +99,8 @@
// Verify persistence
var check = await T.apiGet('/admin/packages/' + editorPkgId + '/settings');
T.assert(check.font_size === 16 || check.data?.font_size === 16,
'font_size should persist as 16');
var vals = check.values || {};
T.assert(vals.font_size === 16, 'font_size should persist as 16');
});
// ── Export/Import Round-Trip ──

View File

@@ -0,0 +1,179 @@
/**
* ICD Test Runner — CRUD: Team Workflows (v0.31.2)
* Team-scoped workflow lifecycle via /teams/:teamId/workflows routes.
* Tests ownership isolation: workflows created via team routes are
* accessible only through team routes, and cross-team access is denied.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.teamWorkflows = async function (testTag) {
// Requires admin user (who is also team admin for fixtures.team)
if (T.user.role !== 'admin') return;
var team = T.fixtures && T.fixtures.team;
if (!team) {
await T.test('crud', 'team-workflows', 'SKIP — no fixture team', async function () {
T.assert(false, 'fixtures.team not provisioned — run fixtures first');
});
return;
}
var teamId = team.id;
var wfId = null;
var wfSlug = testTag.toLowerCase().replace(/[^a-z0-9-]/g, '-') + '-twf';
// ── Create via team route ──
await T.test('crud', 'team-workflows', 'POST /teams/:teamId/workflows (create)', async function () {
var d = await T.apiPost('/teams/' + teamId + '/workflows', {
name: testTag + '-team-workflow',
slug: wfSlug,
description: 'Team workflow CRUD test',
entry_mode: 'team_only'
});
T.assertShape(d, T.S.workflow, 'team workflow');
T.assert(d.slug === wfSlug, 'slug mismatch: expected ' + wfSlug + ', got ' + d.slug);
T.assert(d.team_id === teamId, 'team_id should be ' + teamId + ', got ' + d.team_id);
wfId = d.id;
T.registerCleanup(function () { if (wfId) return T.safeDelete('/workflows/' + wfId); });
});
if (!wfId) return;
// ── Read via team route ──
await T.test('crud', 'team-workflows', 'GET /teams/:teamId/workflows/:id (read)', async function () {
var d = await T.apiGet('/teams/' + teamId + '/workflows/' + wfId);
T.assertShape(d, T.S.workflow, 'team workflow');
T.assert(d.id === wfId, 'id mismatch');
T.assert(d.team_id === teamId, 'team_id mismatch');
});
// ── List via team route ──
await T.test('crud', 'team-workflows', 'GET /teams/:teamId/workflows (list)', async function () {
var d = await T.apiGet('/teams/' + teamId + '/workflows');
T.assertHasKey(d, 'data', '/workflows');
T.assert(Array.isArray(d.data), 'data should be array');
var found = d.data.find(function (w) { return w.id === wfId; });
T.assert(found, 'team workflow should appear in team list');
});
// ── Update via team route ──
await T.test('crud', 'team-workflows', 'PATCH /teams/:teamId/workflows/:id (update)', async function () {
var d = await T.apiPatch('/teams/' + teamId + '/workflows/' + wfId, {
description: 'Updated by team admin',
entry_mode: 'public_link'
});
T.assert(typeof d === 'object', 'expected object');
});
// ── Stage CRUD via team routes ──
var stageIds = [];
await T.test('crud', 'team-workflows', 'POST .../stages (create stage #1)', async function () {
var d = await T.apiPost('/teams/' + teamId + '/workflows/' + wfId + '/stages', {
name: 'Team Intake',
ordinal: 0,
history_mode: 'full',
stage_mode: 'chat_only'
});
T.assertShape(d, T.S.workflowStage, 'stage');
T.assert(d.name === 'Team Intake', 'name mismatch');
stageIds.push(d.id);
});
await T.test('crud', 'team-workflows', 'POST .../stages (create stage #2)', async function () {
var d = await T.apiPost('/teams/' + teamId + '/workflows/' + wfId + '/stages', {
name: 'Team Review',
ordinal: 1,
history_mode: 'summary',
stage_mode: 'review'
});
T.assertShape(d, T.S.workflowStage, 'stage');
stageIds.push(d.id);
});
await T.test('crud', 'team-workflows', 'GET .../stages (list)', async function () {
var d = await T.apiGet('/teams/' + teamId + '/workflows/' + wfId + '/stages');
T.assertHasKey(d, 'data', '/stages');
T.assert(d.data.length >= 2, 'expected at least 2 stages, got ' + d.data.length);
});
await T.test('crud', 'team-workflows', 'PUT .../stages/:sid (update)', async function () {
var d = await T.apiPut('/teams/' + teamId + '/workflows/' + wfId + '/stages/' + stageIds[0], {
name: 'Team Intake (updated)',
ordinal: 0,
history_mode: 'full',
stage_mode: 'form_chat',
form_template: { fields: [{ key: 'name', type: 'text', label: 'Name', required: true }] }
});
T.assert(typeof d === 'object', 'expected object');
});
if (stageIds.length >= 2) {
await T.test('crud', 'team-workflows', 'PATCH .../stages/reorder', async function () {
var reversed = stageIds.slice().reverse();
var d = await T.apiPatch('/teams/' + teamId + '/workflows/' + wfId + '/stages/reorder', {
ordered_ids: reversed
});
T.assert(typeof d === 'object', 'expected object');
});
// Restore order
await T.apiPatch('/teams/' + teamId + '/workflows/' + wfId + '/stages/reorder', {
ordered_ids: stageIds
});
}
// ── Activate + Publish via team route ──
var versionNum = null;
await T.test('crud', 'team-workflows', 'PATCH activate + POST publish', async function () {
await T.apiPatch('/teams/' + teamId + '/workflows/' + wfId, { is_active: true });
var v = await T.apiPost('/teams/' + teamId + '/workflows/' + wfId + '/publish', {});
T.assertShape(v, T.S.workflowVersion, 'version');
T.assertHasKey(v, 'snapshot', 'version');
versionNum = v.version_number;
});
await T.test('crud', 'team-workflows', 'GET .../versions/:v (read)', async function () {
T.assert(versionNum !== null, 'no version number from publish');
var d = await T.apiGet('/teams/' + teamId + '/workflows/' + wfId + '/versions/' + versionNum);
T.assertShape(d, T.S.workflowVersion, 'version');
T.assert(d.version_number === versionNum, 'version should be ' + versionNum);
});
// ── Ownership isolation: cross-team access denied ──
await T.test('crud', 'team-workflows', 'GET /teams/fake-id/workflows/:id → 403', async function () {
var d = await T.authFetch(await T.getAuthToken(), 'GET',
'/teams/00000000-0000-0000-0000-000000000099/workflows/' + wfId, null);
// Should be 403 (not a team member) or 404
T.assert(d._status === 403 || d._status === 404,
'cross-team access should be denied, got ' + d._status);
});
// ── Stage delete via team route ──
if (stageIds.length >= 2) {
await T.test('crud', 'team-workflows', 'DELETE .../stages/:sid', async function () {
await T.apiDelete('/teams/' + teamId + '/workflows/' + wfId + '/stages/' + stageIds.pop());
});
}
// ── Workflow delete via team route ──
await T.test('crud', 'team-workflows', 'DELETE /teams/:teamId/workflows/:id', async function () {
await T.apiDelete('/teams/' + teamId + '/workflows/' + wfId);
wfId = null;
});
};
})();

View File

@@ -73,6 +73,7 @@
'crud/memory.js',
'crud/admin.js',
'crud/workflows.js',
'crud/team-workflows.js',
'crud/tasks.js',
'crud/teams.js',
'crud/personas.js',

View File

@@ -25,6 +25,7 @@
if (C.memory) await C.memory(testTag);
if (C.admin) await C.admin(testTag);
if (C.workflows) await C.workflows(testTag);
if (C.teamWorkflows) await C.teamWorkflows(testTag);
if (C.tasks) await C.tasks(testTag);
if (C.teams) await C.teams(testTag);
if (C.personas) await C.personas(testTag);

View File

@@ -78,8 +78,14 @@
try {
var evt = JSON.parse(raw);
// Handler emits OpenAI-format: choices[0].delta.content
if (evt.choices && evt.choices[0] && evt.choices[0].delta && evt.choices[0].delta.content) {
content += evt.choices[0].delta.content;
// v0.31.2: Also capture reasoning_content (DeepSeek, etc.)
if (evt.choices && evt.choices[0] && evt.choices[0].delta) {
if (evt.choices[0].delta.content) {
content += evt.choices[0].delta.content;
}
if (evt.choices[0].delta.reasoning_content) {
content += evt.choices[0].delta.reasoning_content;
}
} else if (evt.content) {
content += evt.content; // fallback for non-OpenAI format
}
@@ -99,6 +105,8 @@
T.pickCheapestChat = function (models) {
var skipPatterns = ['embed', 'image', 'dall-e', 'tts', 'whisper', 'moderation'];
var chatModels = models.filter(function (m) {
// v0.31.2: Skip E2EE models — require client-side decryption we don't support
if (m.supportsE2EE || m.supports_e2ee) return false;
var t = m.model_type || m.type || '';
if (t === 'embedding' || t === 'image') return false;
// When type is missing, exclude by model ID pattern

View File

@@ -13,6 +13,7 @@
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL UNIQUE,
slug VARCHAR(200) NOT NULL DEFAULT '' UNIQUE,
description TEXT DEFAULT '',
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
is_active BOOLEAN DEFAULT true,

View File

@@ -4,6 +4,7 @@
CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL DEFAULT '' UNIQUE,
description TEXT DEFAULT '',
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
is_active INTEGER DEFAULT 1,

View File

@@ -105,6 +105,7 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
team := &models.Team{
Name: req.Name,
Slug: slugify(req.Name),
Description: req.Description,
CreatedBy: adminID,
IsActive: true,
@@ -118,7 +119,7 @@ func (h *TeamHandler) CreateTeam(c *gin.Context) {
return
}
c.JSON(http.StatusCreated, gin.H{"id": team.ID, "name": team.Name})
c.JSON(http.StatusCreated, gin.H{"id": team.ID, "name": team.Name, "slug": team.Slug})
AuditLog(h.stores.Audit, c, "team.create", "team", team.ID, map[string]interface{}{"name": team.Name})
}
@@ -155,6 +156,7 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
fields := map[string]interface{}{}
if req.Name != nil {
fields["name"] = *req.Name
fields["slug"] = slugify(*req.Name)
}
if req.Description != nil {
fields["description"] = *req.Description

View File

@@ -1,6 +1,7 @@
package handlers
import (
"context"
"fmt"
"log"
"net/http"
@@ -23,6 +24,24 @@ func NewWorkflowEntryHandler(stores store.Stores) *WorkflowEntryHandler {
return &WorkflowEntryHandler{stores: stores}
}
// resolveTeamScope converts a URL scope parameter to a team ID.
// Accepts "global" (returns nil), a UUID (passed through), or a team slug (looked up).
func resolveTeamScope(ctx context.Context, teams store.TeamStore, scope string) *string {
if scope == "global" {
return nil
}
// UUID format: 36 chars with dashes at 8,13,18,23
if len(scope) == 36 && scope[8] == '-' && scope[13] == '-' {
return &scope
}
// Try team slug lookup
t, err := teams.GetBySlug(ctx, scope)
if err == nil && t != nil {
return &t.ID
}
return &scope // fall through — will 404 downstream
}
// StartVisitor creates an anonymous session + workflow instance for a visitor.
// POST /api/v1/workflow-entry/:scope/:slug
func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) {
@@ -30,10 +49,7 @@ func (h *WorkflowEntryHandler) StartVisitor(c *gin.Context) {
scope := c.Param("scope")
slug := c.Param("slug")
var teamID *string
if scope != "global" {
teamID = &scope
}
teamID := resolveTeamScope(ctx, h.stores.Teams, scope)
wf, err := h.stores.Workflows.GetBySlug(ctx, teamID, slug)
if err != nil || wf == nil {

View File

@@ -0,0 +1,157 @@
package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── Team-Scoped Workflow Wrappers (v0.31.2) ────────────────
//
// Thin wrappers that enforce team ownership before delegating
// to the existing WorkflowHandler methods. Follows the same
// pattern as CreateTeamTask / ListTeamTasks.
// requireTeamWorkflow loads the workflow by :id and verifies
// its team_id matches :teamId. Returns false (and writes an
// HTTP error) if the check fails; true if the caller may proceed.
func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool {
teamID := c.Param("teamId")
wfID := c.Param("id")
w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workflow"})
}
return false
}
if w.TeamID == nil || *w.TeamID != teamID {
c.JSON(http.StatusForbidden, gin.H{"error": "workflow does not belong to this team"})
return false
}
return true
}
// ── Workflow CRUD ───────────────────────────
// ListTeamWorkflows returns workflows owned by the team.
// GET /api/v1/teams/:teamId/workflows
func (h *WorkflowHandler) ListTeamWorkflows(c *gin.Context) {
teamID := c.Param("teamId")
result, err := h.stores.Workflows.ListForTeam(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"})
return
}
if result == nil {
result = []models.Workflow{}
}
c.JSON(http.StatusOK, gin.H{"data": result})
}
// CreateTeamWorkflow creates a workflow scoped to the team.
// POST /api/v1/teams/:teamId/workflows
func (h *WorkflowHandler) CreateTeamWorkflow(c *gin.Context) {
teamID := c.Param("teamId")
c.Set("force_team_id", teamID)
h.Create(c)
}
// GetTeamWorkflow returns a team workflow with stages.
// GET /api/v1/teams/:teamId/workflows/:id
func (h *WorkflowHandler) GetTeamWorkflow(c *gin.Context) {
if !h.requireTeamWorkflow(c) {
return
}
h.Get(c)
}
// UpdateTeamWorkflow patches a team workflow.
// PATCH /api/v1/teams/:teamId/workflows/:id
func (h *WorkflowHandler) UpdateTeamWorkflow(c *gin.Context) {
if !h.requireTeamWorkflow(c) {
return
}
h.Update(c)
}
// DeleteTeamWorkflow deletes a team workflow.
// DELETE /api/v1/teams/:teamId/workflows/:id
func (h *WorkflowHandler) DeleteTeamWorkflow(c *gin.Context) {
if !h.requireTeamWorkflow(c) {
return
}
h.Delete(c)
}
// ── Stage CRUD ──────────────────────────────
// ListTeamWorkflowStages returns stages for a team workflow.
// GET /api/v1/teams/:teamId/workflows/:id/stages
func (h *WorkflowHandler) ListTeamWorkflowStages(c *gin.Context) {
if !h.requireTeamWorkflow(c) {
return
}
h.ListStages(c)
}
// CreateTeamWorkflowStage adds a stage to a team workflow.
// POST /api/v1/teams/:teamId/workflows/:id/stages
func (h *WorkflowHandler) CreateTeamWorkflowStage(c *gin.Context) {
if !h.requireTeamWorkflow(c) {
return
}
h.CreateStage(c)
}
// UpdateTeamWorkflowStage updates a stage on a team workflow.
// PUT /api/v1/teams/:teamId/workflows/:id/stages/:sid
func (h *WorkflowHandler) UpdateTeamWorkflowStage(c *gin.Context) {
if !h.requireTeamWorkflow(c) {
return
}
h.UpdateStage(c)
}
// DeleteTeamWorkflowStage removes a stage from a team workflow.
// DELETE /api/v1/teams/:teamId/workflows/:id/stages/:sid
func (h *WorkflowHandler) DeleteTeamWorkflowStage(c *gin.Context) {
if !h.requireTeamWorkflow(c) {
return
}
h.DeleteStage(c)
}
// ReorderTeamWorkflowStages reorders stages on a team workflow.
// PATCH /api/v1/teams/:teamId/workflows/:id/stages/reorder
func (h *WorkflowHandler) ReorderTeamWorkflowStages(c *gin.Context) {
if !h.requireTeamWorkflow(c) {
return
}
h.ReorderStages(c)
}
// ── Publish / Version ───────────────────────
// PublishTeamWorkflow publishes a team workflow version.
// POST /api/v1/teams/:teamId/workflows/:id/publish
func (h *WorkflowHandler) PublishTeamWorkflow(c *gin.Context) {
if !h.requireTeamWorkflow(c) {
return
}
h.Publish(c)
}
// GetTeamWorkflowVersion returns a version snapshot for a team workflow.
// GET /api/v1/teams/:teamId/workflows/:id/versions/:version
func (h *WorkflowHandler) GetTeamWorkflowVersion(c *gin.Context) {
if !h.requireTeamWorkflow(c) {
return
}
h.GetVersion(c)
}

View File

@@ -106,6 +106,12 @@ func (h *WorkflowHandler) Create(c *gin.Context) {
w.CreatedBy = c.GetString("user_id")
// v0.31.2: Team-scoped workflow creation (set by CreateTeamWorkflow)
if forceTeam, ok := c.Get("force_team_id"); ok {
teamID := forceTeam.(string)
w.TeamID = &teamID
}
if err := h.stores.Workflows.Create(c.Request.Context(), &w); err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") {
c.JSON(http.StatusConflict, gin.H{"error": "slug already exists in this scope"})

View File

@@ -500,7 +500,7 @@ func main() {
log.Printf(" 🔑 Auth mode: %s", authMode)
authH := handlers.NewAuthHandler(cfg, stores, uekCache, authProvider)
authLimiter := middleware.NewRateLimiter(5, 8)
authLimiter := middleware.NewRateLimiter(5, 5)
api := base.Group("/api/v1")
{
@@ -990,6 +990,21 @@ func main() {
teamScoped.DELETE("/tasks/:id", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.Delete)
teamScoped.POST("/tasks/:id/run", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.RunNow)
teamScoped.POST("/tasks/:id/kill", middleware.RequirePermission(auth.PermTaskCreate, stores), teamTaskH.KillRun)
// Team workflows — self-service CRUD (v0.31.2)
teamWfH := handlers.NewWorkflowHandler(stores)
teamScoped.GET("/workflows", teamWfH.ListTeamWorkflows)
teamScoped.POST("/workflows", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.CreateTeamWorkflow)
teamScoped.GET("/workflows/:id", teamWfH.GetTeamWorkflow)
teamScoped.PATCH("/workflows/:id", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.UpdateTeamWorkflow)
teamScoped.DELETE("/workflows/:id", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.DeleteTeamWorkflow)
teamScoped.GET("/workflows/:id/stages", teamWfH.ListTeamWorkflowStages)
teamScoped.POST("/workflows/:id/stages", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.CreateTeamWorkflowStage)
teamScoped.PUT("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.UpdateTeamWorkflowStage)
teamScoped.DELETE("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.DeleteTeamWorkflowStage)
teamScoped.PATCH("/workflows/:id/stages/reorder", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.ReorderTeamWorkflowStages)
teamScoped.POST("/workflows/:id/publish", middleware.RequirePermission(auth.PermWorkflowCreate, stores), teamWfH.PublishTeamWorkflow)
teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion)
}
// Team task viewing for all members (v0.27.5)

View File

@@ -92,6 +92,7 @@ type User struct {
type Team struct {
BaseModel
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Description string `json:"description,omitempty" db:"description"`
CreatedBy string `json:"created_by" db:"created_by"`
IsActive bool `json:"is_active" db:"is_active"`

View File

@@ -700,10 +700,23 @@ func (e *Engine) RenderWorkflowLanding() gin.HandlerFunc {
scope := c.Param("id")
slug := c.Param("slug")
// Resolve scope to team_id
// Resolve scope to team_id (v0.31.2: supports team slug)
var teamID *string
if scope != "global" {
teamID = &scope
// UUID format check: 36 chars with dashes at standard positions
if len(scope) == 36 && scope[8] == '-' && scope[13] == '-' {
teamID = &scope
} else if e.stores.Teams != nil {
// Try team slug lookup
t, err := e.stores.Teams.GetBySlug(ctx, scope)
if err == nil && t != nil {
teamID = &t.ID
} else {
teamID = &scope // fall through — will 404 downstream
}
} else {
teamID = &scope
}
}
if e.stores.Workflows == nil {

View File

@@ -374,6 +374,7 @@ window.addEventListener('unhandledrejection', function(e) {
<button class="admin-tab active" data-ttab="members" onclick="sb.call('UI.switchTeamTab','members')">Members</button>
<button class="admin-tab" data-ttab="providers" onclick="sb.call('UI.switchTeamTab','providers')">Providers</button>
<button class="admin-tab" data-ttab="personas" onclick="sb.call('UI.switchTeamTab','personas')">Personas</button>
<button class="admin-tab" data-ttab="workflows" onclick="sb.call('UI.switchTeamTab','workflows')">Workflows</button>
<button class="admin-tab" data-ttab="knowledge" onclick="sb.call('UI.switchTeamTab','knowledge')">Knowledge</button>
<button class="admin-tab" data-ttab="groups" onclick="sb.call('UI.switchTeamTab','groups')">Groups</button>
<button class="admin-tab" data-ttab="settings" onclick="sb.call('UI.switchTeamTab','settings')">Settings</button>
@@ -418,6 +419,9 @@ window.addEventListener('unhandledrejection', function(e) {
<div id="teamKBContent"></div>
</div>
{{/* Workflows (v0.31.2) */}}
<div id="teamTabWorkflows" class="team-tab-content" style="display:none;"></div>
{{/* Groups */}}
<div id="teamTabGroups" class="team-tab-content" style="display:none;">
<div id="settingsTeamGroups"></div>
@@ -524,6 +528,7 @@ window.addEventListener('unhandledrejection', function(e) {
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/admin-handlers.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-api.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-admin.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-team-admin.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/workflow-queue.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/task-sidebar.js?v={{$.Version}}"></script>
<script type="module" nonce="{{$.CSPNonce}}" src="{{$.BasePath}}/js/app.js?v={{$.Version}}"></script>

View File

@@ -340,6 +340,7 @@ type UserSearchResult struct {
type TeamStore interface {
Create(ctx context.Context, t *models.Team) error
GetByID(ctx context.Context, id string) (*models.Team, error)
GetBySlug(ctx context.Context, slug string) (*models.Team, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
List(ctx context.Context) ([]models.Team, error)

View File

@@ -16,10 +16,10 @@ func NewTeamStore() *TeamStore { return &TeamStore{} }
func (s *TeamStore) Create(ctx context.Context, t *models.Team) error {
settingsJSON := ToJSON(t.Settings)
return DB.QueryRowContext(ctx, `
INSERT INTO teams (name, description, created_by, is_active, settings)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO teams (name, slug, description, created_by, is_active, settings)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at, updated_at`,
t.Name, t.Description, t.CreatedBy, t.IsActive, settingsJSON,
t.Name, t.Slug, t.Description, t.CreatedBy, t.IsActive, settingsJSON,
).Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt)
}
@@ -27,10 +27,27 @@ func (s *TeamStore) GetByID(ctx context.Context, id string) (*models.Team, error
var t models.Team
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
SELECT id, name, slug, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams WHERE id = $1`, id).Scan(
&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MemberCount,
)
if err != nil {
return nil, err
}
json.Unmarshal(settingsJSON, &t.Settings)
return &t, nil
}
func (s *TeamStore) GetBySlug(ctx context.Context, slug string) (*models.Team, error) {
var t models.Team
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, name, slug, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams WHERE slug = $1`, slug).Scan(
&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MemberCount,
)
if err != nil {
@@ -64,7 +81,7 @@ func (s *TeamStore) Delete(ctx context.Context, id string) error {
func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
SELECT id, name, slug, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams ORDER BY name`)
if err != nil {
@@ -76,7 +93,7 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
for rows.Next() {
var t models.Team
var settingsJSON []byte
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MemberCount)
if err != nil {
return nil, err
@@ -89,7 +106,7 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
func (s *TeamStore) ListForUser(ctx context.Context, userID string) ([]models.Team, error) {
rows, err := DB.QueryContext(ctx, `
SELECT t.id, t.name, t.description, t.created_by, t.is_active,
SELECT t.id, t.name, t.slug, t.description, t.created_by, t.is_active,
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
tm.role AS my_role,
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count
@@ -106,7 +123,7 @@ func (s *TeamStore) ListForUser(ctx context.Context, userID string) ([]models.Te
for rows.Next() {
var t models.Team
var settingsJSON []byte
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MyRole, &t.MemberCount)
if err != nil {
return nil, err

View File

@@ -22,9 +22,9 @@ func (s *TeamStore) Create(ctx context.Context, t *models.Team) error {
t.CreatedAt = now
t.UpdatedAt = now
_, err := DB.ExecContext(ctx, `
INSERT INTO teams (id, name, description, created_by, is_active, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Description, t.CreatedBy, t.IsActive, settingsJSON,
INSERT INTO teams (id, name, slug, description, created_by, is_active, settings, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.Name, t.Slug, t.Description, t.CreatedBy, t.IsActive, settingsJSON,
now.Format(timeFmt), now.Format(timeFmt),
)
return err
@@ -34,10 +34,27 @@ func (s *TeamStore) GetByID(ctx context.Context, id string) (*models.Team, error
var t models.Team
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
SELECT id, name, slug, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams WHERE id = ?`, id).Scan(
&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MemberCount,
)
if err != nil {
return nil, err
}
json.Unmarshal(settingsJSON, &t.Settings)
return &t, nil
}
func (s *TeamStore) GetBySlug(ctx context.Context, slug string) (*models.Team, error) {
var t models.Team
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, name, slug, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams WHERE slug = ?`, slug).Scan(
&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MemberCount,
)
if err != nil {
@@ -71,7 +88,7 @@ func (s *TeamStore) Delete(ctx context.Context, id string) error {
func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
SELECT id, name, slug, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams ORDER BY name`)
if err != nil {
@@ -83,7 +100,7 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
for rows.Next() {
var t models.Team
var settingsJSON []byte
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MemberCount)
if err != nil {
return nil, err
@@ -96,7 +113,7 @@ func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
func (s *TeamStore) ListForUser(ctx context.Context, userID string) ([]models.Team, error) {
rows, err := DB.QueryContext(ctx, `
SELECT t.id, t.name, t.description, t.created_by, t.is_active,
SELECT t.id, t.name, t.slug, t.description, t.created_by, t.is_active,
COALESCE(t.settings, '{}'), t.created_at, t.updated_at,
tm.role AS my_role,
(SELECT COUNT(*) FROM team_members WHERE team_id = t.id) AS member_count
@@ -113,7 +130,7 @@ func (s *TeamStore) ListForUser(ctx context.Context, userID string) ([]models.Te
for rows.Next() {
var t models.Team
var settingsJSON []byte
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, st(&t.CreatedAt), st(&t.UpdatedAt), &t.MyRole, &t.MemberCount)
if err != nil {
return nil, err

View File

@@ -173,6 +173,7 @@ Object.assign(UI, {
if (tab === 'members') UI.loadTeamManageMembers(teamId);
if (tab === 'providers') UI.loadTeamManageProviders(teamId);
if (tab === 'personas') UI.loadTeamManagePersonas(teamId);
if (tab === 'workflows') UI.loadTeamWorkflows(teamId);
if (tab === 'usage') UI.loadTeamUsage();
if (tab === 'activity') UI.loadTeamAuditLog(1);
if (tab === 'settings') UI.loadTeamManageSettings(teamId);

View File

@@ -117,3 +117,53 @@
API.completeAssignment = function(id) {
return this._post(`/api/v1/workflow-assignments/${id}/complete`, {});
};
// ── Team-Scoped Workflow CRUD (v0.31.2) ──
API.listTeamWorkflows = function(teamId) {
return this._get(`/api/v1/teams/${teamId}/workflows`);
};
API.getTeamWorkflow = function(teamId, id) {
return this._get(`/api/v1/teams/${teamId}/workflows/${id}`);
};
API.createTeamWorkflow = function(teamId, data) {
return this._post(`/api/v1/teams/${teamId}/workflows`, data);
};
API.updateTeamWorkflow = function(teamId, id, patch) {
return this._patch(`/api/v1/teams/${teamId}/workflows/${id}`, patch);
};
API.deleteTeamWorkflow = function(teamId, id) {
return this._del(`/api/v1/teams/${teamId}/workflows/${id}`);
};
API.listTeamWorkflowStages = function(teamId, workflowId) {
return this._get(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages`);
};
API.createTeamWorkflowStage = function(teamId, workflowId, data) {
return this._post(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages`, data);
};
API.updateTeamWorkflowStage = function(teamId, workflowId, stageId, data) {
return this._put(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/${stageId}`, data);
};
API.deleteTeamWorkflowStage = function(teamId, workflowId, stageId) {
return this._del(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/${stageId}`);
};
API.reorderTeamWorkflowStages = function(teamId, workflowId, orderedIds) {
return this._patch(`/api/v1/teams/${teamId}/workflows/${workflowId}/stages/reorder`, { ordered_ids: orderedIds });
};
API.publishTeamWorkflow = function(teamId, workflowId) {
return this._post(`/api/v1/teams/${teamId}/workflows/${workflowId}/publish`, {});
};
API.getTeamWorkflowVersion = function(teamId, workflowId, version) {
return this._get(`/api/v1/teams/${teamId}/workflows/${workflowId}/versions/${version}`);
};

View File

@@ -0,0 +1,707 @@
// ==========================================
// Chat Switchboard — Team Workflow Builder (v0.31.2)
// ==========================================
// Team admin panel for managing team-scoped workflow definitions,
// stages, and publishing. Adapted from workflow-admin.js using
// team-scoped API endpoints. Loaded as a team admin tab.
(function () {
'use strict';
var esc = typeof window.esc === 'function' ? window.esc : function (s) {
var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML;
};
// ── Scaffold HTML ────────────────────────
var SCAFFOLD_HTML =
'<div id="twfList" class="admin-list"></div>' +
'<div id="twfDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">' +
'<button class="btn-small" id="twfBackBtn">&larr; Back</button>' +
'<h4 id="twfDetailName" style="margin:0;flex:1"></h4>' +
'<span id="twfDetailVersion" class="badge"></span>' +
'<span id="twfDetailStatus" class="badge"></span>' +
'</div>' +
'<div class="settings-section" style="margin-bottom:16px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Name</label>' +
'<input type="text" id="twfEditName" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>Slug</label>' +
'<input type="text" id="twfEditSlug" style="width:100%" readonly></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:2"><label>Description</label>' +
'<textarea id="twfEditDesc" rows="2" style="width:100%"></textarea></div>' +
'<div class="form-group" style="flex:1"><label>Entry Mode</label>' +
'<select id="twfEditEntry" style="width:100%">' +
'<option value="team_only">Team Only</option>' +
'<option value="public_link">Public Link</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px;gap:12px;align-items:center">' +
'<label class="toggle-label"><input type="checkbox" id="twfEditActive"><span class="toggle-track"></span><span>Active</span></label>' +
'<button class="btn-small btn-primary" id="twfSaveBtn">Save Changes</button>' +
'<button class="btn-small" id="twfPublishBtn">Publish</button>' +
'<button class="btn-small btn-danger" id="twfDeleteBtn" style="margin-left:auto">Delete</button>' +
'</div>' +
'</div>' +
'<h5 style="margin:0 0 8px">Stages</h5>' +
'<div id="twfStageList" class="admin-list"></div>' +
'<button class="btn-small" id="twfAddStageBtn" style="margin-top:8px">+ Add Stage</button>' +
'<div id="twfStageEditor" class="settings-section" style="display:none;margin-top:12px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Stage Name</label>' +
'<input type="text" id="twfStageName" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>History Mode</label>' +
'<select id="twfStageHistory" style="width:100%">' +
'<option value="full">Full</option>' +
'<option value="summary">Summary</option>' +
'<option value="fresh">Fresh</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Persona</label>' +
'<select id="twfStagePersona" style="width:100%">' +
'<option value="">None (no AI for this stage)</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Stage Mode</label>' +
'<select id="twfStageMode" style="width:100%">' +
'<option value="chat_only">Chat Only</option>' +
'<option value="form_only">Form Only (no AI)</option>' +
'<option value="form_chat">Form + Chat</option>' +
'<option value="review">Review (Human)</option>' +
'</select></div>' +
'</div>' +
'<div id="twfFormBuilderWrap" style="display:none;margin-top:8px">' +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">' +
'<label style="margin:0;font-weight:600">Form Fields</label>' +
'<label class="toggle-label" style="font-size:12px"><input type="checkbox" id="twfFormJsonToggle"><span class="toggle-track"></span><span>Show JSON</span></label>' +
'</div>' +
'<div id="twfFormFields"></div>' +
'<button class="btn-small" id="twfAddFieldBtn" style="margin-top:4px">+ Add Field</button>' +
'<textarea id="twfStageForm" rows="4" style="display:none;width:100%;font-family:monospace;font-size:12px;margin-top:6px" placeholder=\'{"fields":[{"key":"name","type":"text","label":"Full Name","required":true}]}\'></textarea>' +
'</div>' +
'<div class="form-row" style="margin-top:8px;gap:8px">' +
'<button class="btn-small btn-primary" id="twfSaveStageBtn">Save Stage</button>' +
'<button class="btn-small" id="twfCancelStageBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<h5 style="margin:16px 0 8px">Public URL</h5>' +
'<div id="twfPublicUrl" class="text-muted" style="font-size:12px;word-break:break-all"></div>' +
'</div>';
// ── State ───────────────────────────────
var _teamId = null;
var _teamSlug = null;
var _currentWf = null;
var _editingStageId = null;
var _personaCache = [];
function _getTeamId() {
return _teamId || (UI && UI._managingTeamId) || null;
}
// Load team personas into the stage editor dropdown.
async function _twfLoadPersonaSelect(selectedId) {
var sel = document.getElementById('twfStagePersona');
if (!sel) return;
var teamId = _getTeamId();
if (_personaCache.length === 0 && teamId) {
try {
var resp = await API._get('/api/v1/teams/' + teamId + '/personas');
_personaCache = (resp.data || resp || []).map(function(p) {
return { id: p.id, name: p.name, icon: p.icon || '' };
});
} catch (e) {
// Fall back to user-visible personas
try {
var resp2 = await API.listPersonas();
_personaCache = (resp2.data || resp2 || []).map(function(p) {
return { id: p.id, name: p.name, icon: p.icon || '' };
});
} catch (e2) { /* no personas available */ }
}
}
sel.innerHTML = '<option value="">None (no AI for this stage)</option>';
_personaCache.forEach(function(p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = (p.icon ? p.icon + ' ' : '') + p.name;
sel.appendChild(opt);
});
if (selectedId) sel.value = selectedId;
}
// ── Loader (called from switchTeamTab) ──
UI.loadTeamWorkflows = async function(teamId) {
_teamId = teamId;
_teamSlug = null;
_currentWf = null;
_editingStageId = null;
_personaCache = [];
_twfWired = false;
// Fetch team slug for clean public URLs
try {
var teamResp = await API._get('/api/v1/admin/teams/' + teamId);
_teamSlug = teamResp.slug || null;
} catch (e) { /* fallback to team ID in URLs */ }
var target = document.getElementById('teamTabWorkflows');
if (!target) return;
// Self-scaffold
if (!document.getElementById('twfList')) {
target.innerHTML = SCAFFOLD_HTML;
_twfWireFormBuilder();
}
var list = document.getElementById('twfList');
var detail = document.getElementById('twfDetail');
if (!list) return;
detail.style.display = 'none';
list.style.display = '';
try {
var resp = await API.listTeamWorkflows(teamId);
var wfs = resp.data || resp || [];
if (wfs.length === 0) {
list.innerHTML =
'<div class="empty-hint">No workflows for this team</div>' +
'<button class="btn-small btn-primary" style="margin-top:8px" ' +
'data-action="_twfCreate">+ Create Workflow</button>';
return;
}
var html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
'<span class="text-muted">' + wfs.length + ' workflow(s)</span>' +
'<button class="btn-small btn-primary" data-action="_twfCreate">+ New</button>' +
'</div>';
html += '<table class="admin-table"><thead><tr>' +
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
'</tr></thead><tbody>';
for (var i = 0; i < wfs.length; i++) {
var wf = wfs[i];
html += '<tr style="cursor:pointer" data-action="_twfOpen" data-args=\'' + JSON.stringify([wf.id]) + '\'>' +
'<td>' + esc(wf.name) + '</td>' +
'<td><code>' + esc(wf.slug) + '</code></td>' +
'<td>' + esc(wf.entry_mode || 'team_only') + '</td>' +
'<td>' + (wf.is_active ? '✓' : '—') + '</td>' +
'<td>v' + (wf.version || 1) + '</td>' +
'</tr>';
}
html += '</tbody></table>';
list.innerHTML = html;
} catch (e) {
list.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>';
}
};
// ── Create ──────────────────────────────
sb.register('_twfCreate', async function() {
var teamId = _getTeamId();
if (!teamId) return;
var name = prompt('Workflow name:');
if (!name) return;
try {
var wf = await API.createTeamWorkflow(teamId, { name: name });
UI.toast('Workflow created', 'success');
_twfOpen(wf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
});
// ── Open Detail ─────────────────────────
async function _twfOpen(id) {
var teamId = _getTeamId();
var list = document.getElementById('twfList');
var detail = document.getElementById('twfDetail');
try {
var wf = await API.getTeamWorkflow(teamId, id);
_currentWf = wf;
list.style.display = 'none';
detail.style.display = '';
document.getElementById('twfDetailName').textContent = wf.name;
document.getElementById('twfDetailVersion').textContent = 'v' + (wf.version || 1);
document.getElementById('twfDetailStatus').textContent = wf.is_active ? 'Active' : 'Inactive';
document.getElementById('twfDetailStatus').className = 'badge ' + (wf.is_active ? 'badge-ok' : 'badge-warn');
document.getElementById('twfEditName').value = wf.name;
document.getElementById('twfEditSlug').value = wf.slug;
document.getElementById('twfEditDesc').value = wf.description || '';
document.getElementById('twfEditEntry').value = wf.entry_mode || 'team_only';
document.getElementById('twfEditActive').checked = wf.is_active;
// Public URL (v0.31.2: use team slug for clean URLs)
var scope = _teamSlug || wf.team_id || teamId;
var base = location.origin + (window.__BASE__ || '');
document.getElementById('twfPublicUrl').textContent =
wf.entry_mode === 'public_link'
? base + '/w/' + scope + '/' + wf.slug
: 'Enable "Public Link" entry mode to generate a URL';
// Preload persona cache
await _twfLoadPersonaSelect('');
// Load stages
await _twfLoadStages(id);
_twfWireEvents();
} catch (e) {
UI.toast('Failed to load workflow: ' + e.message, 'error');
}
}
sb.register('_twfOpen', function(id) { return _twfOpen(id); });
// ── Stage List ──────────────────────────
async function _twfLoadStages(wfId) {
var teamId = _getTeamId();
var el = document.getElementById('twfStageList');
if (!el) return;
try {
var resp = await API.listTeamWorkflowStages(teamId, wfId);
var stages = resp.data || resp || [];
if (stages.length === 0) {
el.innerHTML = '<div class="empty-hint">No stages — add one below</div>';
return;
}
var html = '';
stages.forEach(function(s, i) {
var personaLabel = '';
if (s.persona_id) {
var p = _personaCache.find(function(x) { return x.id === s.persona_id; });
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
'<span class="badge">persona</span>';
}
var modeLabel = '';
if (s.stage_mode === 'review') {
modeLabel = '<span class="badge badge-warn">review</span>';
} else if (s.stage_mode && s.stage_mode !== 'chat_only') {
modeLabel = '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>';
}
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
'<span class="wf-stage-grip">⠿</span>' +
'<span class="wf-stage-ord">' + i + '</span>' +
'<span class="wf-stage-name">' + esc(s.name) + '</span>' +
personaLabel + modeLabel +
'<span class="badge">' + (s.history_mode || 'full') + '</span>' +
'<button class="btn-small" data-action="_twfEditStage" data-args=\'' + JSON.stringify([s.id]) + '\'>Edit</button>' +
'<button class="btn-small btn-danger" data-action="_twfDeleteStage" data-args=\'' + JSON.stringify([s.id]) + '\'>×</button>' +
'</div>';
});
el.innerHTML = html;
_twfWireStageDnD(el, wfId);
} catch (e) {
el.innerHTML = '<div class="empty-hint">Failed: ' + esc(e.message) + '</div>';
}
}
// ── Stage DnD ───────────────────────────
function _twfWireStageDnD(container, wfId) {
var dragSrc = null;
var teamId = _getTeamId();
container.querySelectorAll('.wf-stage-row').forEach(function(row) {
row.addEventListener('dragstart', function(e) {
dragSrc = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', row.dataset.id);
});
row.addEventListener('dragend', function() {
row.classList.remove('dragging');
container.querySelectorAll('.wf-stage-row').forEach(function(r) {
r.classList.remove('drag-over');
});
dragSrc = null;
});
row.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (row !== dragSrc) row.classList.add('drag-over');
});
row.addEventListener('dragleave', function() {
row.classList.remove('drag-over');
});
row.addEventListener('drop', function(e) {
e.preventDefault();
row.classList.remove('drag-over');
if (!dragSrc || dragSrc === row) return;
var rows = Array.from(container.querySelectorAll('.wf-stage-row'));
var fromIdx = rows.indexOf(dragSrc);
var toIdx = rows.indexOf(row);
if (fromIdx < toIdx) {
row.parentNode.insertBefore(dragSrc, row.nextSibling);
} else {
row.parentNode.insertBefore(dragSrc, row);
}
var orderedIDs = Array.from(container.querySelectorAll('.wf-stage-row')).map(function(r) {
return r.dataset.id;
});
API.reorderTeamWorkflowStages(teamId, wfId, orderedIDs).then(function() {
UI.toast('Stages reordered', 'success');
_twfLoadStages(wfId);
}).catch(function(err) {
UI.toast('Reorder failed: ' + (err.message || err), 'error');
_twfLoadStages(wfId);
});
});
});
}
// ── Wire Events ─────────────────────────
var _twfWired = false;
function _twfWireEvents() {
if (_twfWired) return;
_twfWired = true;
document.getElementById('twfBackBtn').onclick = function() {
_currentWf = null;
_editingStageId = null;
_personaCache = [];
_twfWired = false;
UI.loadTeamWorkflows(_getTeamId());
};
document.getElementById('twfSaveBtn').onclick = async function() {
if (!_currentWf) return;
var teamId = _getTeamId();
try {
await API.updateTeamWorkflow(teamId, _currentWf.id, {
name: document.getElementById('twfEditName').value.trim(),
description: document.getElementById('twfEditDesc').value.trim(),
entry_mode: document.getElementById('twfEditEntry').value,
is_active: document.getElementById('twfEditActive').checked,
});
UI.toast('Saved', 'success');
_twfOpen(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('twfPublishBtn').onclick = async function() {
if (!_currentWf) return;
var teamId = _getTeamId();
try {
var ver = await API.publishTeamWorkflow(teamId, _currentWf.id);
UI.toast('Published v' + ver.version_number, 'success');
_twfOpen(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('twfDeleteBtn').onclick = async function() {
if (!_currentWf) return;
if (!confirm('Delete workflow "' + _currentWf.name + '"? This cannot be undone.')) return;
var teamId = _getTeamId();
try {
await API.deleteTeamWorkflow(teamId, _currentWf.id);
UI.toast('Deleted', 'success');
_currentWf = null;
_personaCache = [];
_twfWired = false;
UI.loadTeamWorkflows(teamId);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('twfAddStageBtn').onclick = async function() {
_editingStageId = null;
document.getElementById('twfStageName').value = '';
document.getElementById('twfStageHistory').value = 'full';
document.getElementById('twfStagePersona').value = '';
document.getElementById('twfStageMode').value = 'chat_only';
document.getElementById('twfStageForm').value = '';
document.getElementById('twfFormFields').innerHTML = '';
_twfUpdateFormBuilderVisibility();
await _twfLoadPersonaSelect('');
document.getElementById('twfStageEditor').style.display = '';
};
document.getElementById('twfCancelStageBtn').onclick = function() {
document.getElementById('twfStageEditor').style.display = 'none';
_editingStageId = null;
};
document.getElementById('twfSaveStageBtn').onclick = async function() {
if (!_currentWf) return;
var teamId = _getTeamId();
var name = document.getElementById('twfStageName').value.trim();
if (!name) { UI.toast('Stage name required', 'error'); return; }
var stageMode = document.getElementById('twfStageMode').value;
var formTemplate = null;
if (stageMode === 'form_only' || stageMode === 'form_chat') {
var jsonToggle = document.getElementById('twfFormJsonToggle');
if (jsonToggle && jsonToggle.checked) {
var formRaw = document.getElementById('twfStageForm').value.trim();
if (formRaw) {
try { formTemplate = JSON.parse(formRaw); }
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
}
} else {
formTemplate = _twfBuildFormTemplateFromUI();
}
if (!formTemplate || !formTemplate.fields || formTemplate.fields.length === 0) {
UI.toast('Form modes require at least one form field', 'error');
return;
}
} else {
var formRaw = document.getElementById('twfStageForm').value.trim();
if (formRaw) {
try { formTemplate = JSON.parse(formRaw); }
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
}
}
var personaId = document.getElementById('twfStagePersona').value || null;
var data = {
name: name,
history_mode: document.getElementById('twfStageHistory').value,
stage_mode: stageMode,
persona_id: personaId,
form_template: formTemplate,
};
try {
if (_editingStageId) {
await API.updateTeamWorkflowStage(teamId, _currentWf.id, _editingStageId, data);
} else {
data.ordinal = document.querySelectorAll('#twfStageList .wf-stage-row').length;
await API.createTeamWorkflowStage(teamId, _currentWf.id, data);
}
UI.toast('Stage saved', 'success');
document.getElementById('twfStageEditor').style.display = 'none';
_editingStageId = null;
await _twfLoadStages(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
}
// ── Edit / Delete Stage ─────────────────
sb.register('_twfEditStage', async function(stageId) {
if (!_currentWf) return;
var teamId = _getTeamId();
try {
var resp = await API.listTeamWorkflowStages(teamId, _currentWf.id);
var stages = resp.data || resp || [];
var stage = stages.find(function(s) { return s.id === stageId; });
if (!stage) return;
_editingStageId = stageId;
document.getElementById('twfStageName').value = stage.name;
document.getElementById('twfStageHistory').value = stage.history_mode || 'full';
document.getElementById('twfStageMode').value = stage.stage_mode || 'chat_only';
await _twfLoadPersonaSelect(stage.persona_id || '');
var tpl = stage.form_template;
document.getElementById('twfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : '';
_twfPopulateFormBuilder(tpl);
_twfUpdateFormBuilderVisibility();
document.getElementById('twfStageEditor').style.display = '';
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
});
sb.register('_twfDeleteStage', async function(stageId) {
if (!_currentWf) return;
if (!confirm('Delete this stage?')) return;
var teamId = _getTeamId();
try {
await API.deleteTeamWorkflowStage(teamId, _currentWf.id, stageId);
UI.toast('Stage deleted', 'success');
await _twfLoadStages(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
});
// ── Form Builder Helpers ────────────────
var FIELD_TYPES = ['text', 'email', 'number', 'date', 'textarea', 'select', 'checkbox', 'file'];
function _twfUpdateFormBuilderVisibility() {
var mode = document.getElementById('twfStageMode').value;
var wrap = document.getElementById('twfFormBuilderWrap');
if (wrap) wrap.style.display = (mode === 'form_only' || mode === 'form_chat') ? '' : 'none';
}
function _twfCreateFieldRow(field) {
field = field || { key: '', type: 'text', label: '', required: false };
var row = document.createElement('div');
row.className = 'wf-field-row';
row.innerHTML =
'<input type="text" class="wf-fb-key" value="' + esc(field.key || '') + '" placeholder="key" style="width:80px">' +
'<select class="wf-fb-type" style="width:90px">' +
FIELD_TYPES.map(function(t) {
return '<option value="' + t + '"' + (t === field.type ? ' selected' : '') + '>' + t + '</option>';
}).join('') +
'</select>' +
'<input type="text" class="wf-fb-label" value="' + esc(field.label || '') + '" placeholder="Label" style="flex:1">' +
'<label class="toggle-label" style="font-size:12px;white-space:nowrap"><input type="checkbox" class="wf-fb-required"' +
(field.required ? ' checked' : '') + '><span class="toggle-track"></span><span>Req</span></label>' +
'<button class="btn-small btn-danger wf-fb-del" style="padding:2px 6px">×</button>';
row.querySelector('.wf-fb-del').onclick = function() {
row.remove();
_twfSyncFormJSON();
};
row.querySelector('.wf-fb-type').onchange = function() {
var optWrap = row.querySelector('.wf-fb-options');
if (this.value === 'select') {
if (!optWrap) {
optWrap = document.createElement('div');
optWrap.className = 'wf-fb-options';
optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px';
optWrap.innerHTML = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
row.appendChild(optWrap);
}
optWrap.style.display = '';
} else if (optWrap) {
optWrap.style.display = 'none';
}
_twfSyncFormJSON();
};
if (field.type === 'select') {
var optWrap = document.createElement('div');
optWrap.className = 'wf-fb-options';
optWrap.style.cssText = 'margin:4px 0 0 28px;font-size:12px';
optWrap.innerHTML = '<input type="text" class="wf-fb-opts-input" placeholder="option1, option2, ..." style="width:100%;font-size:12px" value="' +
esc((field.options || []).map(function(o) { return typeof o === 'string' ? o : o.value || o.label || ''; }).join(', ')) + '">';
row.appendChild(optWrap);
}
row.querySelectorAll('input, select').forEach(function(el) {
el.addEventListener('change', _twfSyncFormJSON);
el.addEventListener('input', _twfSyncFormJSON);
});
return row;
}
function _twfPopulateFormBuilder(tpl) {
var container = document.getElementById('twfFormFields');
if (!container) return;
container.innerHTML = '';
if (!tpl || !tpl.fields || !Array.isArray(tpl.fields)) return;
tpl.fields.forEach(function(f) {
if (typeof f === 'string') {
f = { key: f, type: 'text', label: f, required: false };
}
container.appendChild(_twfCreateFieldRow(f));
});
}
function _twfBuildFormTemplateFromUI() {
var container = document.getElementById('twfFormFields');
if (!container) return null;
var rows = container.querySelectorAll('.wf-field-row');
if (rows.length === 0) return null;
var fields = [];
rows.forEach(function(row) {
var key = row.querySelector('.wf-fb-key').value.trim();
var type = row.querySelector('.wf-fb-type').value;
var label = row.querySelector('.wf-fb-label').value.trim();
var required = row.querySelector('.wf-fb-required').checked;
if (!key) return;
var field = { key: key, type: type, label: label || key, required: required };
if (type === 'select') {
var optsInput = row.querySelector('.wf-fb-opts-input');
if (optsInput && optsInput.value.trim()) {
field.options = optsInput.value.split(',').map(function(s) {
s = s.trim();
return { value: s, label: s };
}).filter(function(o) { return o.value; });
}
}
fields.push(field);
});
return fields.length > 0 ? { fields: fields } : null;
}
function _twfSyncFormJSON() {
var jsonToggle = document.getElementById('twfFormJsonToggle');
if (!jsonToggle) return;
var tpl = _twfBuildFormTemplateFromUI();
document.getElementById('twfStageForm').value = tpl ? JSON.stringify(tpl, null, 2) : '';
}
function _twfWireFormBuilder() {
var modeSelect = document.getElementById('twfStageMode');
if (modeSelect) {
modeSelect.onchange = _twfUpdateFormBuilderVisibility;
}
var addBtn = document.getElementById('twfAddFieldBtn');
if (addBtn) {
addBtn.onclick = function() {
var container = document.getElementById('twfFormFields');
if (container) {
container.appendChild(_twfCreateFieldRow());
_twfSyncFormJSON();
}
};
}
var jsonToggle = document.getElementById('twfFormJsonToggle');
if (jsonToggle) {
jsonToggle.onchange = function() {
var textarea = document.getElementById('twfStageForm');
if (this.checked) {
_twfSyncFormJSON();
textarea.style.display = '';
} else {
var raw = textarea.value.trim();
if (raw) {
try {
var tpl = JSON.parse(raw);
_twfPopulateFormBuilder(tpl);
} catch (e) {
UI.toast('Invalid JSON — fix before switching to visual mode', 'error');
this.checked = true;
return;
}
}
textarea.style.display = 'none';
}
};
}
}
})();