Changeset 0.31.2 (#205)
This commit is contained in:
41
CHANGELOG.md
41
CHANGELOG.md
@@ -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
|
||||
|
||||
101
docs/ROADMAP.md
101
docs/ROADMAP.md
@@ -41,9 +41,11 @@ v0.9.x–v0.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,
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
179
packages/icd-test-runner/js/crud/team-workflows.js
Normal file
179
packages/icd-test-runner/js/crud/team-workflows.js
Normal 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;
|
||||
});
|
||||
|
||||
};
|
||||
})();
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
157
server/handlers/workflow_team.go
Normal file
157
server/handlers/workflow_team.go
Normal 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)
|
||||
}
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
707
src/js/workflow-team-admin.js
Normal file
707
src/js/workflow-team-admin.js
Normal 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">← 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';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user