From 6668e546febf0a0a2f431e0f0d16d16d28b102c2 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Thu, 19 Mar 2026 13:29:27 +0000 Subject: [PATCH] Changeset 0.31.2 (#205) --- CHANGELOG.md | 41 + VERSION | 2 +- docs/ROADMAP.md | 101 ++- .../js/crud/dashboard-package.js | 4 +- .../icd-test-runner/js/crud/editor-package.js | 4 +- .../icd-test-runner/js/crud/team-workflows.js | 179 +++++ packages/icd-test-runner/js/main.js | 1 + packages/icd-test-runner/js/tier-crud.js | 1 + packages/icd-test-runner/js/tier-providers.js | 12 +- server/database/migrations/002_teams.sql | 1 + .../database/migrations/sqlite/002_teams.sql | 1 + server/handlers/teams.go | 4 +- server/handlers/workflow_entry.go | 24 +- server/handlers/workflow_team.go | 157 ++++ server/handlers/workflows.go | 6 + server/main.go | 17 +- server/models/models.go | 1 + server/pages/pages.go | 17 +- server/pages/templates/surfaces/chat.html | 5 + server/store/interfaces.go | 1 + server/store/postgres/team.go | 35 +- server/store/sqlite/team.go | 35 +- src/js/ui-admin.js | 1 + src/js/workflow-api.js | 50 ++ src/js/workflow-team-admin.js | 707 ++++++++++++++++++ 25 files changed, 1357 insertions(+), 50 deletions(-) create mode 100644 packages/icd-test-runner/js/crud/team-workflows.js create mode 100644 server/handlers/workflow_team.go create mode 100644 src/js/workflow-team-admin.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eb7f88..0bd7061 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/VERSION b/VERSION index f176c94..c415e1c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.31.1 +0.31.2 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0dcec7e..67615e8 100644 --- a/docs/ROADMAP.md +++ b/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, diff --git a/packages/icd-test-runner/js/crud/dashboard-package.js b/packages/icd-test-runner/js/crud/dashboard-package.js index 1fcc7c5..bfd934b 100644 --- a/packages/icd-test-runner/js/crud/dashboard-package.js +++ b/packages/icd-test-runner/js/crud/dashboard-package.js @@ -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 ── diff --git a/packages/icd-test-runner/js/crud/editor-package.js b/packages/icd-test-runner/js/crud/editor-package.js index 6e5097d..35a44c6 100644 --- a/packages/icd-test-runner/js/crud/editor-package.js +++ b/packages/icd-test-runner/js/crud/editor-package.js @@ -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 ── diff --git a/packages/icd-test-runner/js/crud/team-workflows.js b/packages/icd-test-runner/js/crud/team-workflows.js new file mode 100644 index 0000000..9913c9f --- /dev/null +++ b/packages/icd-test-runner/js/crud/team-workflows.js @@ -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; + }); + + }; +})(); diff --git a/packages/icd-test-runner/js/main.js b/packages/icd-test-runner/js/main.js index ff9f2b6..cca9b83 100644 --- a/packages/icd-test-runner/js/main.js +++ b/packages/icd-test-runner/js/main.js @@ -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', diff --git a/packages/icd-test-runner/js/tier-crud.js b/packages/icd-test-runner/js/tier-crud.js index 744bb32..b0c480e 100644 --- a/packages/icd-test-runner/js/tier-crud.js +++ b/packages/icd-test-runner/js/tier-crud.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); diff --git a/packages/icd-test-runner/js/tier-providers.js b/packages/icd-test-runner/js/tier-providers.js index 8c56757..9c13590 100644 --- a/packages/icd-test-runner/js/tier-providers.js +++ b/packages/icd-test-runner/js/tier-providers.js @@ -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 diff --git a/server/database/migrations/002_teams.sql b/server/database/migrations/002_teams.sql index 759cd26..aa6036f 100644 --- a/server/database/migrations/002_teams.sql +++ b/server/database/migrations/002_teams.sql @@ -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, diff --git a/server/database/migrations/sqlite/002_teams.sql b/server/database/migrations/sqlite/002_teams.sql index 1edd9e3..d157e38 100644 --- a/server/database/migrations/sqlite/002_teams.sql +++ b/server/database/migrations/sqlite/002_teams.sql @@ -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, diff --git a/server/handlers/teams.go b/server/handlers/teams.go index ed6d1d3..b0c7178 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -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 diff --git a/server/handlers/workflow_entry.go b/server/handlers/workflow_entry.go index b36b91b..54df211 100644 --- a/server/handlers/workflow_entry.go +++ b/server/handlers/workflow_entry.go @@ -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 { diff --git a/server/handlers/workflow_team.go b/server/handlers/workflow_team.go new file mode 100644 index 0000000..b824996 --- /dev/null +++ b/server/handlers/workflow_team.go @@ -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) +} diff --git a/server/handlers/workflows.go b/server/handlers/workflows.go index 5ee4810..3cf61ea 100644 --- a/server/handlers/workflows.go +++ b/server/handlers/workflows.go @@ -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"}) diff --git a/server/main.go b/server/main.go index 2c9f046..d331a1d 100644 --- a/server/main.go +++ b/server/main.go @@ -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) diff --git a/server/models/models.go b/server/models/models.go index c7ad465..f7b99f7 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -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"` diff --git a/server/pages/pages.go b/server/pages/pages.go index 941ccc2..b834687 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -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 { diff --git a/server/pages/templates/surfaces/chat.html b/server/pages/templates/surfaces/chat.html index bb0a696..ba82eda 100644 --- a/server/pages/templates/surfaces/chat.html +++ b/server/pages/templates/surfaces/chat.html @@ -374,6 +374,7 @@ window.addEventListener('unhandledrejection', function(e) { + @@ -418,6 +419,9 @@ window.addEventListener('unhandledrejection', function(e) {
+ {{/* Workflows (v0.31.2) */}} + + {{/* Groups */}}