Feat v0.3.5 settings icd clone #19
29
CHANGELOG.md
29
CHANGELOG.md
@@ -2,6 +2,35 @@
|
||||
|
||||
All notable changes to Switchboard Core are documented here.
|
||||
|
||||
## v0.3.5 — Settings Audit + ICD + Tests + Clone
|
||||
|
||||
### Added
|
||||
|
||||
- **Clone endpoint**: `POST /api/v1/workflows/:id/clone` deep-copies a workflow
|
||||
and all its stages. Creates an inactive draft with "Copy of" prefix. Handles
|
||||
slug collisions with auto-suffix. Team-scoped mirror at
|
||||
`/api/v1/teams/:teamId/workflows/:id/clone`.
|
||||
- **Integration tests**: 7 new engine-level tests in `workflow_engine_test.go`
|
||||
covering full lifecycle, branch routing, public entry, signoff validation gate,
|
||||
signoff rejection, cancel-clears-assignments, and error cases. Total: 28 tests.
|
||||
- **Staleness timeout UI**: `staleness_timeout_hours` input added to workflow
|
||||
editor in team-admin settings (was backend-only since v0.3.3).
|
||||
- **Branch rules UI**: Collapsible JSON textarea in stage form for configuring
|
||||
conditional routing rules (was backend-only since v0.3.2).
|
||||
|
||||
### Changed
|
||||
|
||||
- **ICD (OpenAPI spec)**: Fixed stale `Workflow` schema (added `description`,
|
||||
`branding`, `is_active`, `on_complete`, `retention`, `staleness_timeout_hours`).
|
||||
Fixed stale `Stage` schema (removed `history_mode`/`transition_rules`, updated
|
||||
`stage_mode` enum to `[form, review, delegated, automated]`, added `audience`,
|
||||
`stage_type`, `starlark_hook`, `branch_rules`, `stage_config`, `sla_seconds`,
|
||||
`auto_transition`, `assignment_team_id`, `surface_pkg_id`).
|
||||
Added `WorkflowInstance`, `WorkflowAssignment`, `WorkflowSignoff` schemas.
|
||||
Added ~20 new endpoint paths: instances, assignments, signoffs, public entry,
|
||||
clone, and team roles. Added tags: Workflow Instances, Workflow Assignments,
|
||||
Workflow Signoffs, Public Workflows.
|
||||
|
||||
## v0.3.4 — Team Roles + Multi-party Validation
|
||||
|
||||
### Added
|
||||
|
||||
45
ROADMAP.md
45
ROADMAP.md
@@ -173,10 +173,47 @@ finalizes the extension lifecycle model.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Settings audit pass 2 | ⬚ | Validate team admin + user settings for workflow/team-role changes. |
|
||||
| Integration tests | ⬚ | Full instance lifecycle coverage. |
|
||||
| ICD update | ⬚ | OpenAPI spec with all new endpoints. |
|
||||
| Clone endpoint | ⬚ | `POST /api/v1/workflows/:id/clone` — deep copy workflow + stages. |
|
||||
| Clone endpoint | ✅ | `POST /api/v1/workflows/:id/clone` — deep copy workflow + stages. Handler-level composition of existing store methods. |
|
||||
| Integration tests | ✅ | 7 engine-level tests in `workflow_engine_test.go`: full lifecycle, branch routing, public entry, signoff gate, rejection, cancel-clears-assignments, error cases. 28 total tests. |
|
||||
| Settings audit pass 2 | ✅ | Added `staleness_timeout_hours` field to workflow editor. Added collapsible branch_rules JSON editor in stage form. |
|
||||
| ICD update | ✅ | Fixed stale Workflow/Stage schemas. Added WorkflowInstance, WorkflowAssignment, WorkflowSignoff schemas. Added ~20 new endpoints: instances, assignments, signoffs, public entry, clone, team roles. |
|
||||
|
||||
### v0.3.6 — Example Workflows + Interactive Demo
|
||||
|
||||
Four installable `.pkg` workflow packages that prove the engine works
|
||||
end-to-end, plus a demo surface for guided exploration.
|
||||
See `docs/DEMO-WORKFLOWS.md` for full stage definitions and Starlark code.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Bug Report Triage | | Public entry, progressive fieldsets, severity-based branch routing, team assignment, SLA timer. Pure-manifest `.pkg`. |
|
||||
| Employee Onboarding | | Starlark automated stages (`db.insert`, `notifications.send`), signoff gate with `required_role`, rejection reroute. `.pkg` with `script.star`. |
|
||||
| Content Approval | | Multi-party signoff (quorum of 2), rejection reroute creating review cycle. `.pkg` demonstrating signoff + reroute loop. |
|
||||
| Webhook Notifier | | Starlark `http.post` + `connections.get` for outbound webhooks, delivery logging to ext_data. Proves HTTP + connections modules. |
|
||||
| Demo surface | | Browser-tier walkthrough: workflow cards, stage diagrams, Starlark viewer, "Try It" buttons, API curl examples. Auto-installed, removable. |
|
||||
| Engine context fix | | Add `started_by` to automated stage Starlark context dict (backward-compatible). |
|
||||
| Documentation | | `docs/DEMO-WORKFLOWS.md`: feature capability matrix, stage flows, API walkthrough, per-package READMEs. |
|
||||
|
||||
### v0.3.7 — Package Audit
|
||||
|
||||
Verify all non-chat packages install and render correctly.
|
||||
Tag chat-dependent packages with `requires` and `status: "dormant"`.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Browser package audit | | Install and verify all standalone packages (hello-dashboard, dashboard, schedules, editor, tasks, team-activity-log, test runners). |
|
||||
| Chat-dependency tagging | | Add `"requires": ["chat"]` and `"status": "dormant"` to csv-table, diff-viewer, js-sandbox, katex-renderer, mermaid-renderer, regex-tester, git-board, gitea-client. |
|
||||
| Fix broken packages | | Repair any install, rendering, or API issues found during audit. |
|
||||
|
||||
### v0.3.8 — Distribution
|
||||
|
||||
Builder image for faster builds, bundled packages for zero-config first run.
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| Builder image | | `Dockerfile.builder` with cached Go modules + Node vendor libs. Published to Docker Hub for faster custom builds. |
|
||||
| Bundled packages | | Dockerfile stage builds example workflows + demo surface into production image. Auto-installs on first run (standard install path, admin can uninstall). `SKIP_BUNDLED_PACKAGES=true` to disable. |
|
||||
| Distribution docs | | `docs/DISTRIBUTION.md` with builder image usage, custom build guide, production deployment recommendations. |
|
||||
|
||||
## v0.4.0 — Notes Surface
|
||||
|
||||
|
||||
330
docs/DEMO-WORKFLOWS.md
Normal file
330
docs/DEMO-WORKFLOWS.md
Normal file
@@ -0,0 +1,330 @@
|
||||
# Demo Workflows — v0.3.6
|
||||
|
||||
Four example workflow packages that prove the engine works end-to-end.
|
||||
Each is a self-contained `.pkg` archive installable via Admin > Packages.
|
||||
|
||||
## Feature Capability Matrix
|
||||
|
||||
| Feature | Bug Report | Onboarding | Content Approval | Webhook |
|
||||
|---------|:----------:|:----------:|:----------------:|:-------:|
|
||||
| Public entry (anonymous) | ✅ | | | |
|
||||
| Progressive fieldsets | ✅ | ✅ | | |
|
||||
| Branch rules (conditional routing) | ✅ | | | |
|
||||
| Team assignment + claim | ✅ | | | |
|
||||
| SLA timer | ✅ | | | |
|
||||
| Starlark automated stage | | ✅ | | ✅ |
|
||||
| db.insert (ext_data tables) | | ✅ | | ✅ |
|
||||
| notifications.send | | ✅ | | |
|
||||
| http.post (outbound webhook) | | | | ✅ |
|
||||
| connections.get (credentials) | | | | ✅ |
|
||||
| Signoff gate (required_role) | | ✅ | | |
|
||||
| Multi-party signoff (quorum) | | | ✅ | |
|
||||
| Rejection reroute | | ✅ | ✅ | |
|
||||
| Review cycle (loop) | | | ✅ | |
|
||||
|
||||
---
|
||||
|
||||
## Package 1: Bug Report Triage
|
||||
|
||||
**Slug:** `bug-report-triage`
|
||||
**Entry mode:** `public_link` — anyone with the link can submit
|
||||
**Tier:** browser (no Starlark needed)
|
||||
|
||||
### Stage Flow
|
||||
|
||||
```
|
||||
[submit] → [classify] → [fix-critical] → [verify]
|
||||
public team ↘ [fix-normal] ↗ team
|
||||
team
|
||||
```
|
||||
|
||||
### Stages
|
||||
|
||||
| # | Name | Mode | Audience | Key Feature |
|
||||
|---|------|------|----------|-------------|
|
||||
| 0 | submit | form | public | 2 fieldsets: "Report Details" (email, summary, component, severity) + "Reproduction" (steps, expected, actual) |
|
||||
| 1 | classify | form | team | Team reviews. Branch rules: severity=critical → fix-critical, else → fix-normal |
|
||||
| 2 | fix-critical | form | team | Senior team queue. SLA: 3600s |
|
||||
| 3 | fix-normal | form | team | Standard fix queue |
|
||||
| 4 | verify | review | team | Verify fix, close out |
|
||||
|
||||
### Branch Rules (stage 1)
|
||||
|
||||
```json
|
||||
[
|
||||
{"field": "severity", "op": "eq", "value": "critical", "target_stage": "fix-critical"},
|
||||
{"field": "severity", "op": "neq", "value": "critical", "target_stage": "fix-normal"}
|
||||
]
|
||||
```
|
||||
|
||||
### API Walkthrough
|
||||
|
||||
```bash
|
||||
# 1. Start public instance (no auth required)
|
||||
curl -X POST $BASE/api/v1/public/workflows/$WF_ID/start \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"data": {"reporter_email": "user@example.com", "summary": "Login broken", "component": "ui", "severity": "critical", "steps": "1. Click login", "expected": "Login page", "actual": "500 error"}}'
|
||||
|
||||
# Response includes entry_token for anonymous continuation
|
||||
# {"id": "inst-xxx", "entry_token": "tok-yyy", ...}
|
||||
|
||||
# 2. Resume (check status)
|
||||
curl $BASE/api/v1/public/workflows/resume/tok-yyy
|
||||
|
||||
# 3. Team member claims the classify assignment
|
||||
curl -X POST $BASE/api/v1/teams/$TEAM/assignments/$ASSIGN_ID/claim \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# 4. Advance classify stage (triggers branch rule)
|
||||
curl -X POST $BASE/api/v1/teams/$TEAM/instances/$INST/advance \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"data": {"severity": "critical", "notes": "Confirmed production issue"}}'
|
||||
# → Routes to fix-critical (not fix-normal)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Package 2: Employee Onboarding
|
||||
|
||||
**Slug:** `employee-onboarding`
|
||||
**Entry mode:** `team_only`
|
||||
**Tier:** starlark
|
||||
**Permissions:** `db.write`, `notifications.send`
|
||||
|
||||
### Stage Flow
|
||||
|
||||
```
|
||||
[new-hire-info] → [provision-accounts] → [manager-signoff] → [it-setup] → [welcome]
|
||||
team automated (Starlark) review + signoff team automated
|
||||
auto-advances reject → stage 0 auto-advances
|
||||
```
|
||||
|
||||
### Stages
|
||||
|
||||
| # | Name | Mode | Audience | Key Feature |
|
||||
|---|------|------|----------|-------------|
|
||||
| 0 | new-hire-info | form | team | 2 fieldsets: "Personal Info" + "Access Requirements" |
|
||||
| 1 | provision-accounts | automated | system | Starlark: db.insert provisioning records, notify HR |
|
||||
| 2 | manager-signoff | review | team | 1 approval required, required_role: "manager", reject → new-hire-info |
|
||||
| 3 | it-setup | form | team | IT confirms hardware + access |
|
||||
| 4 | welcome | automated | system | Starlark: log completion, send welcome notification |
|
||||
|
||||
### Starlark Hooks
|
||||
|
||||
**`on_provision(ctx)`** — Automated provisioning:
|
||||
|
||||
```python
|
||||
def on_provision(ctx):
|
||||
data = ctx["stage_data"]
|
||||
name = data.get("full_name", "unknown")
|
||||
dept = data.get("department", "general")
|
||||
|
||||
# Record provision in ext_data table
|
||||
db.insert("provisions", {
|
||||
"employee_name": name,
|
||||
"department": dept,
|
||||
"needs_vpn": str(data.get("needs_vpn", False)),
|
||||
"needs_admin": str(data.get("needs_admin", False)),
|
||||
"equipment": data.get("equipment", "standard laptop"),
|
||||
"status": "provisioned"
|
||||
})
|
||||
|
||||
# Notify the HR user who started this workflow
|
||||
notifications.send(
|
||||
ctx["started_by"],
|
||||
"Accounts provisioned for " + name,
|
||||
"Department: " + dept + ". Ready for manager review."
|
||||
)
|
||||
|
||||
return {"advance": True, "data": {
|
||||
"provision_status": "complete",
|
||||
"accounts": ["email", "vpn" if data.get("needs_vpn") else None, "admin" if data.get("needs_admin") else None]
|
||||
}}
|
||||
```
|
||||
|
||||
**`on_welcome(ctx)`** — Completion logging:
|
||||
|
||||
```python
|
||||
def on_welcome(ctx):
|
||||
data = ctx["stage_data"]
|
||||
|
||||
db.insert("onboarding_log", {
|
||||
"employee_name": data.get("full_name", ""),
|
||||
"department": data.get("department", ""),
|
||||
"completed_by": ctx["started_by"],
|
||||
"status": "complete"
|
||||
})
|
||||
|
||||
notifications.send(
|
||||
ctx["started_by"],
|
||||
"Onboarding complete: " + data.get("full_name", ""),
|
||||
"All stages finished. Welcome aboard!"
|
||||
)
|
||||
|
||||
return {"advance": True, "data": {"onboarding_complete": True}}
|
||||
```
|
||||
|
||||
### Signoff Gate (stage 2)
|
||||
|
||||
```json
|
||||
{
|
||||
"validation": {
|
||||
"required_approvals": 1,
|
||||
"required_role": "manager",
|
||||
"reject_action": "new-hire-info"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Team must have a custom "manager" role (via Team Admin > Members > Team Roles)
|
||||
- At least one team member with the "manager" role assigned
|
||||
|
||||
---
|
||||
|
||||
## Package 3: Content Approval
|
||||
|
||||
**Slug:** `content-approval`
|
||||
**Entry mode:** `team_only`
|
||||
**Tier:** browser
|
||||
|
||||
### Stage Flow
|
||||
|
||||
```
|
||||
[draft] → [review] → [publish]
|
||||
team team ↕ team
|
||||
[revision]
|
||||
team
|
||||
```
|
||||
|
||||
### Stages
|
||||
|
||||
| # | Name | Mode | Audience | Key Feature |
|
||||
|---|------|------|----------|-------------|
|
||||
| 0 | draft | form | team | Author submits title, body, category |
|
||||
| 1 | review | review | team | 2 approvals required. Reject → revision |
|
||||
| 2 | revision | form | team | Author revises. Branch rule always routes back to review |
|
||||
| 3 | publish | form | team | Final confirmation |
|
||||
|
||||
### Review Cycle
|
||||
|
||||
The rejection reroute creates a loop:
|
||||
|
||||
1. Author submits draft → enters review
|
||||
2. Reviewer A approves, Reviewer B rejects → `reject_action: "revision"` routes to stage 2
|
||||
3. Author revises → branch rule routes back to review (stage 1)
|
||||
4. Both reviewers approve → advances to publish (stage 3)
|
||||
|
||||
### Signoff Gate (stage 1)
|
||||
|
||||
```json
|
||||
{
|
||||
"validation": {
|
||||
"required_approvals": 2,
|
||||
"reject_action": "revision"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Package 4: Webhook Notifier
|
||||
|
||||
**Slug:** `webhook-notifier`
|
||||
**Entry mode:** `team_only`
|
||||
**Tier:** starlark
|
||||
**Permissions:** `api.http`, `connections.read`, `db.write`
|
||||
|
||||
### Stage Flow
|
||||
|
||||
```
|
||||
[configure] → [fire-webhook] → [result]
|
||||
team automated review
|
||||
(http.post)
|
||||
```
|
||||
|
||||
### Stages
|
||||
|
||||
| # | Name | Mode | Audience | Key Feature |
|
||||
|---|------|------|----------|-------------|
|
||||
| 0 | configure | form | team | URL + event name + payload |
|
||||
| 1 | fire-webhook | automated | system | Starlark: http.post to webhook URL |
|
||||
| 2 | result | review | team | Shows delivery status |
|
||||
|
||||
### Starlark Hook
|
||||
|
||||
**`on_fire(ctx)`** — Outbound HTTP:
|
||||
|
||||
```python
|
||||
def on_fire(ctx):
|
||||
data = ctx["stage_data"]
|
||||
url = data.get("webhook_url", "")
|
||||
|
||||
# Fallback to configured connection
|
||||
if not url:
|
||||
conn = connections.get("webhook")
|
||||
if conn:
|
||||
url = conn.get("url", "")
|
||||
|
||||
if not url:
|
||||
return {"error": "No webhook URL configured"}
|
||||
|
||||
payload = json.encode({
|
||||
"event": data.get("event_name", "workflow.notify"),
|
||||
"instance_id": ctx["instance_id"],
|
||||
"workflow_id": ctx["workflow_id"],
|
||||
"data": data
|
||||
})
|
||||
|
||||
resp = http.post(url, body=payload, headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Switchboard-Event": data.get("event_name", "workflow.notify")
|
||||
})
|
||||
|
||||
# Log the delivery
|
||||
db.insert("webhook_log", {
|
||||
"url": url,
|
||||
"status_code": str(resp["status"]),
|
||||
"event": data.get("event_name", ""),
|
||||
"instance_id": ctx["instance_id"]
|
||||
})
|
||||
|
||||
return {"advance": True, "data": {
|
||||
"delivery_status": "delivered" if int(resp["status"]) < 400 else "failed",
|
||||
"status_code": resp["status"],
|
||||
"response_body": resp["body"][:500]
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Demo Surface
|
||||
|
||||
A browser-tier surface (`workflow-demo`) auto-installed on first run. Provides:
|
||||
|
||||
- Workflow cards with feature badges and stage flow diagrams
|
||||
- "Try It" buttons for interactive exploration
|
||||
- Starlark code viewer showing hook source
|
||||
- API curl examples for each operation
|
||||
|
||||
Can be uninstalled by admin like any other package.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build all example packages
|
||||
cd packages && ./build.sh bug-report-triage employee-onboarding content-approval webhook-notifier workflow-demo
|
||||
|
||||
# Install via admin API
|
||||
for pkg in dist/bug-report-triage.pkg dist/employee-onboarding.pkg dist/content-approval.pkg dist/webhook-notifier.pkg dist/workflow-demo.pkg; do
|
||||
curl -X POST $BASE/api/v1/admin/packages/install \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "file=@$pkg"
|
||||
done
|
||||
```
|
||||
|
||||
Or with bundled packages (v0.3.8+): just `docker compose up` — all examples are auto-installed.
|
||||
470
server/handlers/workflow_engine_test.go
Normal file
470
server/handlers/workflow_engine_test.go
Normal file
@@ -0,0 +1,470 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"switchboard-core/database"
|
||||
"switchboard-core/models"
|
||||
"switchboard-core/workflow"
|
||||
)
|
||||
|
||||
// testEngine creates a workflow engine with nil bus and runner (safe for tests).
|
||||
func testEngine(t *testing.T) *workflow.Engine {
|
||||
t.Helper()
|
||||
s := testStores(t)
|
||||
return workflow.NewEngine(s, nil, nil)
|
||||
}
|
||||
|
||||
// seedEngineFixture creates a 3-stage workflow (form → review → form),
|
||||
// publishes it, and returns (workflowID, userID, teamID).
|
||||
func seedEngineFixture(t *testing.T, slug string) (string, string, string) {
|
||||
t.Helper()
|
||||
s := testStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "Eng-"+slug, slug+"@test.com")
|
||||
teamID := database.SeedTestTeam(t, "Team-"+slug, userID)
|
||||
|
||||
wf := &models.Workflow{
|
||||
TeamID: &teamID,
|
||||
Name: "Engine " + slug,
|
||||
Slug: slug,
|
||||
EntryMode: "team_only",
|
||||
IsActive: true,
|
||||
CreatedBy: userID,
|
||||
}
|
||||
if err := s.Workflows.Create(ctx, wf); err != nil {
|
||||
t.Fatalf("create workflow: %v", err)
|
||||
}
|
||||
|
||||
stages := []models.WorkflowStage{
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, AssignmentTeamID: &teamID},
|
||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "final", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
}
|
||||
for i := range stages {
|
||||
if err := s.Workflows.CreateStage(ctx, &stages[i]); err != nil {
|
||||
t.Fatalf("create stage %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
snapshot, _ := json.Marshal(stages)
|
||||
ver := &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot}
|
||||
if err := s.Workflows.Publish(ctx, ver); err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
|
||||
return wf.ID, userID, teamID
|
||||
}
|
||||
|
||||
// ── Full Lifecycle ──────────────────────────
|
||||
|
||||
func TestEngine_FullLifecycle(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
eng := testEngine(t)
|
||||
ctx := context.Background()
|
||||
|
||||
wfID, userID, _ := seedEngineFixture(t, "lifecycle")
|
||||
|
||||
// Start
|
||||
inst, err := eng.Start(ctx, wfID, json.RawMessage(`{"name":"Alice"}`), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if inst.Status != models.InstanceStatusActive {
|
||||
t.Fatalf("status = %q, want active", inst.Status)
|
||||
}
|
||||
if inst.CurrentStage != "intake" {
|
||||
t.Fatalf("stage = %q, want intake", inst.CurrentStage)
|
||||
}
|
||||
|
||||
// Advance intake → review
|
||||
inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{"submitted":true}`), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("advance 1: %v", err)
|
||||
}
|
||||
if inst.CurrentStage != "review" {
|
||||
t.Fatalf("stage = %q, want review", inst.CurrentStage)
|
||||
}
|
||||
|
||||
// Advance review → final
|
||||
inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{"reviewed":true}`), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("advance 2: %v", err)
|
||||
}
|
||||
if inst.CurrentStage != "final" {
|
||||
t.Fatalf("stage = %q, want final", inst.CurrentStage)
|
||||
}
|
||||
|
||||
// Advance final → completed (past last stage)
|
||||
inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("advance 3: %v", err)
|
||||
}
|
||||
if inst.Status != models.InstanceStatusCompleted {
|
||||
t.Fatalf("status = %q, want completed", inst.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Branch Routing ──────────────────────────
|
||||
|
||||
func TestEngine_BranchRouting(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
s := testStores(t)
|
||||
eng := workflow.NewEngine(s, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "Branch", "branch@test.com")
|
||||
teamID := database.SeedTestTeam(t, "Branch Team", userID)
|
||||
|
||||
wf := &models.Workflow{
|
||||
TeamID: &teamID, Name: "Branching WF", Slug: "branch-wf",
|
||||
EntryMode: "team_only", IsActive: true, CreatedBy: userID,
|
||||
}
|
||||
s.Workflows.Create(ctx, wf)
|
||||
|
||||
branchRules, _ := json.Marshal([]map[string]any{
|
||||
{"field": "priority", "op": "eq", "value": "high", "target_stage": "escalation"},
|
||||
})
|
||||
|
||||
stages := []models.WorkflowStage{
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "intake", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "normal-review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "escalation", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
}
|
||||
for i := range stages {
|
||||
s.Workflows.CreateStage(ctx, &stages[i])
|
||||
}
|
||||
snapshot, _ := json.Marshal(stages)
|
||||
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
||||
|
||||
// Test: priority=high → escalation
|
||||
inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID)
|
||||
inst, err := eng.Advance(ctx, inst.ID, json.RawMessage(`{"priority":"high"}`), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("advance: %v", err)
|
||||
}
|
||||
if inst.CurrentStage != "escalation" {
|
||||
t.Fatalf("stage = %q, want escalation", inst.CurrentStage)
|
||||
}
|
||||
|
||||
// Test: no priority → normal-review (ordinal+1)
|
||||
inst2, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID)
|
||||
inst2, err = eng.Advance(ctx, inst2.ID, json.RawMessage(`{"priority":"low"}`), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("advance2: %v", err)
|
||||
}
|
||||
if inst2.CurrentStage != "normal-review" {
|
||||
t.Fatalf("stage = %q, want normal-review", inst2.CurrentStage)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public Entry ────────────────────────────
|
||||
|
||||
func TestEngine_PublicEntry(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
s := testStores(t)
|
||||
eng := workflow.NewEngine(s, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "PubUser", "pub@test.com")
|
||||
teamID := database.SeedTestTeam(t, "Pub Team", userID)
|
||||
|
||||
wf := &models.Workflow{
|
||||
TeamID: &teamID, Name: "Public WF", Slug: "public-wf",
|
||||
EntryMode: "public_link", IsActive: true, CreatedBy: userID,
|
||||
}
|
||||
s.Workflows.Create(ctx, wf)
|
||||
|
||||
stages := []models.WorkflowStage{
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "public-form", StageMode: models.StageModeForm, Audience: models.AudiencePublic, StageType: models.StageTypeSimple},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "team-review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
}
|
||||
for i := range stages {
|
||||
s.Workflows.CreateStage(ctx, &stages[i])
|
||||
}
|
||||
snapshot, _ := json.Marshal(stages)
|
||||
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
||||
|
||||
// StartPublic
|
||||
inst, err := eng.StartPublic(ctx, wf.ID, json.RawMessage(`{"email":"anon@example.com"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("start public: %v", err)
|
||||
}
|
||||
if inst.EntryToken == nil || *inst.EntryToken == "" {
|
||||
t.Fatal("expected entry_token")
|
||||
}
|
||||
token := *inst.EntryToken
|
||||
|
||||
// ResumePublic
|
||||
resumed, err := eng.ResumePublic(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
if resumed.ID != inst.ID {
|
||||
t.Fatalf("resumed different instance")
|
||||
}
|
||||
|
||||
// AdvancePublic on public stage — should succeed
|
||||
inst, err = eng.AdvancePublic(ctx, token, json.RawMessage(`{"submitted":true}`))
|
||||
if err != nil {
|
||||
t.Fatalf("advance public: %v", err)
|
||||
}
|
||||
if inst.CurrentStage != "team-review" {
|
||||
t.Fatalf("stage = %q, want team-review", inst.CurrentStage)
|
||||
}
|
||||
|
||||
// AdvancePublic on team stage — should fail
|
||||
_, err = eng.AdvancePublic(ctx, token, json.RawMessage(`{}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-public stage")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "authenticated") {
|
||||
t.Fatalf("error = %q, want 'authenticated' mention", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signoff Validation Gate ─────────────────
|
||||
|
||||
func TestEngine_SignoffGate(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
s := testStores(t)
|
||||
eng := workflow.NewEngine(s, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
user1 := database.SeedTestUser(t, "Signer1", "s1@test.com")
|
||||
user2 := database.SeedTestUser(t, "Signer2", "s2@test.com")
|
||||
teamID := database.SeedTestTeam(t, "Gate Team", user1)
|
||||
|
||||
wf := &models.Workflow{
|
||||
TeamID: &teamID, Name: "Gate WF", Slug: "gate-wf",
|
||||
EntryMode: "team_only", IsActive: true, CreatedBy: user1,
|
||||
}
|
||||
s.Workflows.Create(ctx, wf)
|
||||
|
||||
validationCfg, _ := json.Marshal(map[string]any{
|
||||
"validation": map[string]any{"required_approvals": 2},
|
||||
})
|
||||
stages := []models.WorkflowStage{
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "gated-stage", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "done", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
}
|
||||
for i := range stages {
|
||||
s.Workflows.CreateStage(ctx, &stages[i])
|
||||
}
|
||||
snapshot, _ := json.Marshal(stages)
|
||||
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
||||
|
||||
inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), user1)
|
||||
|
||||
// 1 approval — advance should fail
|
||||
eng.SubmitSignoff(ctx, inst.ID, user1, models.SignoffApprove, "ok")
|
||||
_, err := eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), user1)
|
||||
if err == nil {
|
||||
t.Fatal("expected insufficient approvals error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "insufficient approvals") {
|
||||
t.Fatalf("error = %q, want 'insufficient approvals'", err.Error())
|
||||
}
|
||||
|
||||
// 2nd approval — advance should succeed
|
||||
eng.SubmitSignoff(ctx, inst.ID, user2, models.SignoffApprove, "also ok")
|
||||
inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), user1)
|
||||
if err != nil {
|
||||
t.Fatalf("advance with 2 approvals: %v", err)
|
||||
}
|
||||
if inst.CurrentStage != "done" {
|
||||
t.Fatalf("stage = %q, want done", inst.CurrentStage)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signoff Rejection ───────────────────────
|
||||
|
||||
func TestEngine_SignoffRejection(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
s := testStores(t)
|
||||
eng := workflow.NewEngine(s, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "Rejector", "rej@test.com")
|
||||
teamID := database.SeedTestTeam(t, "Reject Team", userID)
|
||||
|
||||
wf := &models.Workflow{
|
||||
TeamID: &teamID, Name: "Reject WF", Slug: "reject-wf",
|
||||
EntryMode: "team_only", IsActive: true, CreatedBy: userID,
|
||||
}
|
||||
s.Workflows.Create(ctx, wf)
|
||||
|
||||
validationCfg, _ := json.Marshal(map[string]any{
|
||||
"validation": map[string]any{"required_approvals": 1, "reject_action": "cancel"},
|
||||
})
|
||||
stages := []models.WorkflowStage{
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "review", StageMode: models.StageModeReview, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, StageConfig: validationCfg},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "approved", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
}
|
||||
for i := range stages {
|
||||
s.Workflows.CreateStage(ctx, &stages[i])
|
||||
}
|
||||
snapshot, _ := json.Marshal(stages)
|
||||
s.Workflows.Publish(ctx, &models.WorkflowVersion{WorkflowID: wf.ID, VersionNumber: 1, Snapshot: snapshot})
|
||||
|
||||
inst, _ := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID)
|
||||
|
||||
// Submit rejection
|
||||
eng.SubmitSignoff(ctx, inst.ID, userID, models.SignoffReject, "not good")
|
||||
|
||||
// Advance should cancel the instance
|
||||
inst, err := eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("advance after reject: %v", err)
|
||||
}
|
||||
if inst.Status != models.InstanceStatusCancelled {
|
||||
t.Fatalf("status = %q, want cancelled", inst.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cancel Clears Assignments ───────────────
|
||||
|
||||
func TestEngine_CancelClearsAssignments(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
s := testStores(t)
|
||||
eng := workflow.NewEngine(s, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
wfID, userID, _ := seedEngineFixture(t, "cancel-assign")
|
||||
|
||||
inst, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID)
|
||||
// Advance to review stage (which has assignment_team_id)
|
||||
inst, _ = eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), userID)
|
||||
if inst.CurrentStage != "review" {
|
||||
t.Fatalf("stage = %q, want review", inst.CurrentStage)
|
||||
}
|
||||
|
||||
// Verify assignment was created
|
||||
assignments, _ := s.Workflows.ListAssignmentsByInstance(ctx, inst.ID)
|
||||
openCount := 0
|
||||
for _, a := range assignments {
|
||||
if a.Status == models.AssignmentStatusUnassigned || a.Status == models.AssignmentStatusClaimed {
|
||||
openCount++
|
||||
}
|
||||
}
|
||||
if openCount == 0 {
|
||||
t.Fatal("expected at least one open assignment")
|
||||
}
|
||||
|
||||
// Claim one
|
||||
for _, a := range assignments {
|
||||
if a.Status == models.AssignmentStatusUnassigned && a.Stage == "review" {
|
||||
s.Workflows.ClaimAssignment(ctx, a.ID, userID)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel instance
|
||||
if err := eng.Cancel(ctx, inst.ID, userID); err != nil {
|
||||
t.Fatalf("cancel: %v", err)
|
||||
}
|
||||
|
||||
// Verify all open assignments are cancelled
|
||||
assignments, _ = s.Workflows.ListAssignmentsByInstance(ctx, inst.ID)
|
||||
for _, a := range assignments {
|
||||
if a.Stage == "review" && a.Status != models.AssignmentStatusCancelled {
|
||||
t.Errorf("assignment %s status = %q, want cancelled", a.ID, a.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify instance is cancelled
|
||||
inst2, _ := s.Workflows.GetInstance(ctx, inst.ID)
|
||||
if inst2.Status != models.InstanceStatusCancelled {
|
||||
t.Fatalf("instance status = %q, want cancelled", inst2.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error Cases ─────────────────────────────
|
||||
|
||||
func TestEngine_ErrorCases(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
s := testStores(t)
|
||||
eng := workflow.NewEngine(s, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
wfID, userID, _ := seedEngineFixture(t, "errors")
|
||||
|
||||
// Start on inactive workflow
|
||||
inactive := &models.Workflow{
|
||||
Name: "Inactive", Slug: "inactive-wf", EntryMode: "team_only",
|
||||
IsActive: false, CreatedBy: userID,
|
||||
}
|
||||
s.Workflows.Create(ctx, inactive)
|
||||
_, err := eng.Start(ctx, inactive.ID, json.RawMessage(`{}`), userID)
|
||||
if err == nil {
|
||||
t.Fatal("expected error starting inactive workflow")
|
||||
}
|
||||
|
||||
// Advance on completed instance
|
||||
inst, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID)
|
||||
s.Workflows.CompleteInstance(ctx, inst.ID)
|
||||
_, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{}`), userID)
|
||||
if err == nil {
|
||||
t.Fatal("expected error advancing completed instance")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not active") {
|
||||
t.Fatalf("error = %q, want 'not active'", err.Error())
|
||||
}
|
||||
|
||||
// Cancel on already-cancelled instance
|
||||
inst2, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID)
|
||||
eng.Cancel(ctx, inst2.ID, userID)
|
||||
err = eng.Cancel(ctx, inst2.ID, userID)
|
||||
if err == nil {
|
||||
t.Fatal("expected error cancelling already-cancelled instance")
|
||||
}
|
||||
|
||||
// Double claim
|
||||
inst3, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID)
|
||||
eng.Advance(ctx, inst3.ID, json.RawMessage(`{}`), userID) // → review (creates assignment)
|
||||
assignments, _ := s.Workflows.ListAssignmentsByInstance(ctx, inst3.ID)
|
||||
var reviewAssign string
|
||||
for _, a := range assignments {
|
||||
if a.Stage == "review" && a.Status == models.AssignmentStatusUnassigned {
|
||||
reviewAssign = a.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if reviewAssign != "" {
|
||||
s.Workflows.ClaimAssignment(ctx, reviewAssign, userID)
|
||||
err = s.Workflows.ClaimAssignment(ctx, reviewAssign, userID)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on double claim")
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate signoff
|
||||
inst4, _ := eng.Start(ctx, wfID, json.RawMessage(`{}`), userID)
|
||||
so := &models.WorkflowSignoff{InstanceID: inst4.ID, Stage: "intake", UserID: userID, Decision: models.SignoffApprove}
|
||||
s.Workflows.CreateSignoff(ctx, so)
|
||||
dup := &models.WorkflowSignoff{InstanceID: inst4.ID, Stage: "intake", UserID: userID, Decision: models.SignoffApprove}
|
||||
err = s.Workflows.CreateSignoff(ctx, dup)
|
||||
if err == nil {
|
||||
t.Fatal("expected UNIQUE violation for duplicate signoff")
|
||||
}
|
||||
|
||||
// StartPublic on team_only workflow
|
||||
_, err = eng.StartPublic(ctx, wfID, json.RawMessage(`{}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for StartPublic on team_only workflow")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "public entry") {
|
||||
t.Fatalf("error = %q, want 'public entry'", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
@@ -13,6 +14,21 @@ import (
|
||||
"switchboard-core/store/sqlite"
|
||||
)
|
||||
|
||||
// jsonEq compares two JSON byte slices ignoring whitespace and key order
|
||||
// (PG JSONB normalizes spacing and may reorder keys).
|
||||
func jsonEq(a, b json.RawMessage) bool {
|
||||
var va, vb interface{}
|
||||
if err := json.Unmarshal(a, &va); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(b, &vb); err != nil {
|
||||
return false
|
||||
}
|
||||
na, _ := json.Marshal(va)
|
||||
nb, _ := json.Marshal(vb)
|
||||
return bytes.Equal(na, nb)
|
||||
}
|
||||
|
||||
// testStores returns an appropriate Stores for the current dialect.
|
||||
func testStores(t *testing.T) store.Stores {
|
||||
t.Helper()
|
||||
@@ -128,7 +144,7 @@ func TestWorkflowInstance_CreateAndGet(t *testing.T) {
|
||||
if got.EntryToken == nil || *got.EntryToken != token {
|
||||
t.Errorf("entry_token = %v, want %q", got.EntryToken, token)
|
||||
}
|
||||
if string(got.StageData) != `{"key":"val"}` {
|
||||
if !jsonEq(got.StageData, json.RawMessage(`{"key":"val"}`)) {
|
||||
t.Errorf("stage_data = %s, want {\"key\":\"val\"}", got.StageData)
|
||||
}
|
||||
if got.CreatedAt.IsZero() {
|
||||
@@ -200,8 +216,8 @@ func TestWorkflowInstance_Update(t *testing.T) {
|
||||
if got.CurrentStage != stage2 {
|
||||
t.Errorf("current_stage = %q, want %q", got.CurrentStage, stage2)
|
||||
}
|
||||
if string(got.StageData) != `{"updated":true}` {
|
||||
t.Errorf("stage_data = %s", got.StageData)
|
||||
if !jsonEq(got.StageData, json.RawMessage(`{"updated":true}`)) {
|
||||
t.Errorf("stage_data = %s, want {\"updated\":true}", got.StageData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,8 +312,8 @@ func TestWorkflowInstance_AdvanceStage(t *testing.T) {
|
||||
if got.CurrentStage != stage2 {
|
||||
t.Errorf("current_stage = %q, want %q", got.CurrentStage, stage2)
|
||||
}
|
||||
if string(got.StageData) != `{"step":"two"}` {
|
||||
t.Errorf("stage_data = %s", got.StageData)
|
||||
if !jsonEq(got.StageData, json.RawMessage(`{"step":"two"}`)) {
|
||||
t.Errorf("stage_data = %s, want {\"step\":\"two\"}", got.StageData)
|
||||
}
|
||||
if !got.StageEnteredAt.After(originalEnteredAt) {
|
||||
t.Errorf("stage_entered_at should have advanced; original=%v, got=%v",
|
||||
@@ -543,8 +559,8 @@ func TestWorkflowAssignment_Complete(t *testing.T) {
|
||||
if got.Status != models.AssignmentStatusCompleted {
|
||||
t.Errorf("status = %q, want completed", got.Status)
|
||||
}
|
||||
if string(got.ReviewData) != `{"approved":true,"notes":"LGTM"}` {
|
||||
t.Errorf("review_data = %s", got.ReviewData)
|
||||
if !jsonEq(got.ReviewData, json.RawMessage(`{"approved":true,"notes":"LGTM"}`)) {
|
||||
t.Errorf("review_data = %s, want {\"approved\":true,\"notes\":\"LGTM\"}", got.ReviewData)
|
||||
}
|
||||
if got.CompletedAt == nil {
|
||||
t.Error("completed_at should be set")
|
||||
@@ -837,7 +853,7 @@ func TestWorkflowSignoff_ListEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// ListSignoffs on non-existent instance should return empty, not error
|
||||
list, err := s.Workflows.ListSignoffs(ctx, "nonexistent", "stage")
|
||||
list, err := s.Workflows.ListSignoffs(ctx, "00000000-0000-0000-0000-000000000000", "stage")
|
||||
if err != nil {
|
||||
t.Fatalf("list signoffs error: %v", err)
|
||||
}
|
||||
@@ -846,6 +862,92 @@ func TestWorkflowSignoff_ListEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clone tests (v0.3.5) ──────────────────
|
||||
|
||||
func TestWorkflow_Clone(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
s := testStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "Cloner", "cloner@test.com")
|
||||
teamID := database.SeedTestTeam(t, "Clone Team", userID)
|
||||
wfID, _, _, _ := seedWorkflowFixture(t, s, userID, teamID)
|
||||
|
||||
// Read source
|
||||
src, err := s.Workflows.GetByID(ctx, wfID)
|
||||
if err != nil {
|
||||
t.Fatalf("get source: %v", err)
|
||||
}
|
||||
srcStages, err := s.Workflows.ListStages(ctx, wfID)
|
||||
if err != nil {
|
||||
t.Fatalf("list source stages: %v", err)
|
||||
}
|
||||
|
||||
// Clone
|
||||
clone := &models.Workflow{
|
||||
TeamID: src.TeamID,
|
||||
Name: "Copy of " + src.Name,
|
||||
Slug: src.Slug + "-copy",
|
||||
EntryMode: src.EntryMode,
|
||||
IsActive: false,
|
||||
CreatedBy: userID,
|
||||
}
|
||||
if err := s.Workflows.Create(ctx, clone); err != nil {
|
||||
t.Fatalf("create clone: %v", err)
|
||||
}
|
||||
for _, st := range srcStages {
|
||||
cst := &models.WorkflowStage{
|
||||
WorkflowID: clone.ID,
|
||||
Ordinal: st.Ordinal,
|
||||
Name: st.Name,
|
||||
StageMode: st.StageMode,
|
||||
Audience: st.Audience,
|
||||
StageType: st.StageType,
|
||||
}
|
||||
if err := s.Workflows.CreateStage(ctx, cst); err != nil {
|
||||
t.Fatalf("clone stage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify clone
|
||||
if clone.ID == src.ID {
|
||||
t.Error("clone ID must differ from source")
|
||||
}
|
||||
if clone.Name != "Copy of Test Workflow" {
|
||||
t.Errorf("name = %q, want 'Copy of Test Workflow'", clone.Name)
|
||||
}
|
||||
if clone.IsActive {
|
||||
t.Error("clone should be inactive")
|
||||
}
|
||||
|
||||
clonedStages, _ := s.Workflows.ListStages(ctx, clone.ID)
|
||||
if len(clonedStages) != len(srcStages) {
|
||||
t.Fatalf("cloned stages = %d, want %d", len(clonedStages), len(srcStages))
|
||||
}
|
||||
for i, cs := range clonedStages {
|
||||
if cs.ID == srcStages[i].ID {
|
||||
t.Errorf("stage %d: clone ID should differ from source", i)
|
||||
}
|
||||
if cs.Name != srcStages[i].Name {
|
||||
t.Errorf("stage %d: name = %q, want %q", i, cs.Name, srcStages[i].Name)
|
||||
}
|
||||
if cs.Ordinal != srcStages[i].Ordinal {
|
||||
t.Errorf("stage %d: ordinal = %d, want %d", i, cs.Ordinal, srcStages[i].Ordinal)
|
||||
}
|
||||
}
|
||||
|
||||
// Source unmodified
|
||||
srcAfter, _ := s.Workflows.GetByID(ctx, wfID)
|
||||
if srcAfter.Name != src.Name {
|
||||
t.Error("source workflow was modified")
|
||||
}
|
||||
srcStagesAfter, _ := s.Workflows.ListStages(ctx, wfID)
|
||||
if len(srcStagesAfter) != len(srcStages) {
|
||||
t.Error("source stages were modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowSignoff_Count(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
@@ -155,3 +155,13 @@ func (h *WorkflowHandler) GetTeamWorkflowVersion(c *gin.Context) {
|
||||
}
|
||||
h.GetVersion(c)
|
||||
}
|
||||
|
||||
// CloneTeamWorkflow deep-copies a team workflow.
|
||||
// POST /api/v1/teams/:teamId/workflows/:id/clone
|
||||
func (h *WorkflowHandler) CloneTeamWorkflow(c *gin.Context) {
|
||||
if !h.requireTeamWorkflow(c) {
|
||||
return
|
||||
}
|
||||
c.Set("force_team_id", c.Param("teamId"))
|
||||
h.Clone(c)
|
||||
}
|
||||
|
||||
@@ -376,6 +376,98 @@ func (h *WorkflowHandler) GetVersion(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, v)
|
||||
}
|
||||
|
||||
// ── Clone ────────────────────────────────────
|
||||
|
||||
// Clone deep-copies a workflow and all its stages.
|
||||
// POST /api/v1/workflows/:id/clone
|
||||
func (h *WorkflowHandler) Clone(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
srcID := c.Param("id")
|
||||
|
||||
src, err := h.stores.Workflows.GetByID(ctx, srcID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workflow"})
|
||||
return
|
||||
}
|
||||
|
||||
stages, err := h.stores.Workflows.ListStages(ctx, srcID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"})
|
||||
return
|
||||
}
|
||||
|
||||
clone := &models.Workflow{
|
||||
TeamID: src.TeamID,
|
||||
Name: "Copy of " + src.Name,
|
||||
Slug: src.Slug + "-copy",
|
||||
Description: src.Description,
|
||||
Branding: src.Branding,
|
||||
EntryMode: src.EntryMode,
|
||||
IsActive: false,
|
||||
Version: 0,
|
||||
OnComplete: src.OnComplete,
|
||||
Retention: src.Retention,
|
||||
WebhookURL: src.WebhookURL,
|
||||
WebhookSecret: src.WebhookSecret,
|
||||
StalenessTimeoutHours: src.StalenessTimeoutHours,
|
||||
CreatedBy: c.GetString("user_id"),
|
||||
}
|
||||
|
||||
// v0.31.2: team-scoped route injects force_team_id
|
||||
if ftid, ok := c.Get("force_team_id"); ok {
|
||||
tid := ftid.(string)
|
||||
clone.TeamID = &tid
|
||||
}
|
||||
|
||||
if err := h.stores.Workflows.Create(ctx, clone); err != nil {
|
||||
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") {
|
||||
// Slug collision — append short suffix
|
||||
clone.Slug = src.Slug + "-copy-" + store.NewID()[:6]
|
||||
if err2 := h.stores.Workflows.Create(ctx, clone); err2 != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create clone: " + err2.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create clone: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, st := range stages {
|
||||
cloneSt := &models.WorkflowStage{
|
||||
WorkflowID: clone.ID,
|
||||
Ordinal: st.Ordinal,
|
||||
Name: st.Name,
|
||||
AssignmentTeamID: st.AssignmentTeamID,
|
||||
FormTemplate: st.FormTemplate,
|
||||
StageMode: st.StageMode,
|
||||
Audience: st.Audience,
|
||||
StageType: st.StageType,
|
||||
AutoTransition: st.AutoTransition,
|
||||
StageConfig: st.StageConfig,
|
||||
BranchRules: st.BranchRules,
|
||||
StarlarkHook: st.StarlarkHook,
|
||||
SurfacePkgID: st.SurfacePkgID,
|
||||
SLASeconds: st.SLASeconds,
|
||||
}
|
||||
if err := h.stores.Workflows.CreateStage(ctx, cloneSt); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to clone stage: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
clonedStages, _ := h.stores.Workflows.ListStages(ctx, clone.ID)
|
||||
if clonedStages == nil {
|
||||
clonedStages = []models.WorkflowStage{}
|
||||
}
|
||||
clone.Stages = clonedStages
|
||||
c.JSON(http.StatusCreated, clone)
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
func slugify(name string) string {
|
||||
|
||||
@@ -416,6 +416,7 @@ func main() {
|
||||
protected.DELETE("/workflows/:id/stages/:sid", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.DeleteStage)
|
||||
protected.PATCH("/workflows/:id/stages/reorder", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.ReorderStages)
|
||||
protected.POST("/workflows/:id/publish", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Publish)
|
||||
protected.POST("/workflows/:id/clone", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfH.Clone)
|
||||
protected.GET("/workflows/:id/versions/:version", wfH.GetVersion)
|
||||
|
||||
// Workflow instances + assignments (v0.3.2)
|
||||
@@ -560,6 +561,7 @@ func main() {
|
||||
teamScoped.DELETE("/workflows/:id/stages/:sid", teamWfH.DeleteTeamWorkflowStage)
|
||||
teamScoped.PATCH("/workflows/:id/stages/reorder", teamWfH.ReorderTeamWorkflowStages)
|
||||
teamScoped.POST("/workflows/:id/publish", teamWfH.PublishTeamWorkflow)
|
||||
teamScoped.POST("/workflows/:id/clone", teamWfH.CloneTeamWorkflow)
|
||||
teamScoped.GET("/workflows/:id/versions/:version", teamWfH.GetTeamWorkflowVersion)
|
||||
|
||||
// Team workflow instances + assignments (v0.3.2)
|
||||
|
||||
@@ -44,6 +44,10 @@ tags:
|
||||
- name: Presence
|
||||
- name: Notifications
|
||||
- name: Workflows
|
||||
- name: Workflow Instances
|
||||
- name: Workflow Assignments
|
||||
- name: Workflow Signoffs
|
||||
- name: Public Workflows
|
||||
- name: Packages
|
||||
- name: Connections
|
||||
- name: Teams
|
||||
@@ -378,20 +382,35 @@ components:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
entry_mode:
|
||||
type: string
|
||||
enum: [public_link, team_only]
|
||||
scope:
|
||||
type: string
|
||||
team_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
is_active:
|
||||
type: boolean
|
||||
version:
|
||||
type: integer
|
||||
branding:
|
||||
type: object
|
||||
on_complete:
|
||||
type: object
|
||||
retention:
|
||||
type: object
|
||||
webhook_url:
|
||||
type: string
|
||||
webhook_secret:
|
||||
type: string
|
||||
staleness_timeout_hours:
|
||||
type: integer
|
||||
nullable: true
|
||||
created_by:
|
||||
type: string
|
||||
format: uuid
|
||||
version:
|
||||
type: integer
|
||||
stages:
|
||||
type: array
|
||||
items:
|
||||
@@ -414,22 +433,42 @@ components:
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
history_mode:
|
||||
type: string
|
||||
enum: [full, summary, fresh]
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [custom, form_only, form_chat, review]
|
||||
ordinal:
|
||||
type: integer
|
||||
stage_mode:
|
||||
type: string
|
||||
enum: [form, review, delegated, automated]
|
||||
audience:
|
||||
type: string
|
||||
enum: [team, public, system]
|
||||
stage_type:
|
||||
type: string
|
||||
enum: [simple, dynamic, automated]
|
||||
form_template:
|
||||
type: object
|
||||
transition_rules:
|
||||
stage_config:
|
||||
type: object
|
||||
created_at:
|
||||
branch_rules:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
starlark_hook:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
nullable: true
|
||||
assignment_team_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
surface_pkg_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
auto_transition:
|
||||
type: boolean
|
||||
sla_seconds:
|
||||
type: integer
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
@@ -450,6 +489,100 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
WorkflowInstance:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
workflow_id:
|
||||
type: string
|
||||
format: uuid
|
||||
workflow_version:
|
||||
type: integer
|
||||
current_stage:
|
||||
type: string
|
||||
stage_data:
|
||||
type: object
|
||||
status:
|
||||
type: string
|
||||
enum: [active, completed, cancelled, stale, error]
|
||||
started_by:
|
||||
type: string
|
||||
entry_token:
|
||||
type: string
|
||||
nullable: true
|
||||
metadata:
|
||||
type: object
|
||||
stage_entered_at:
|
||||
type: string
|
||||
format: date-time
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
WorkflowAssignment:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
instance_id:
|
||||
type: string
|
||||
format: uuid
|
||||
stage:
|
||||
type: string
|
||||
team_id:
|
||||
type: string
|
||||
format: uuid
|
||||
assigned_to:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
status:
|
||||
type: string
|
||||
enum: [unassigned, claimed, completed, cancelled]
|
||||
review_data:
|
||||
type: object
|
||||
nullable: true
|
||||
claimed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
completed_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
WorkflowSignoff:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
instance_id:
|
||||
type: string
|
||||
format: uuid
|
||||
stage:
|
||||
type: string
|
||||
user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
decision:
|
||||
type: string
|
||||
enum: [approve, reject]
|
||||
comment:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
Package:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1742,6 +1875,414 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Workflow Clone (v0.3.5)
|
||||
# ──────────────────────────────────────────────
|
||||
/api/v1/workflows/{id}/clone:
|
||||
post:
|
||||
summary: Deep-copy a workflow and its stages
|
||||
operationId: cloneWorkflow
|
||||
tags: [Workflows]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/idPath'
|
||||
responses:
|
||||
'201':
|
||||
description: Cloned workflow
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Workflow'
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Workflow Instances (v0.3.2)
|
||||
# ──────────────────────────────────────────────
|
||||
/api/v1/workflows/{id}/instances:
|
||||
post:
|
||||
summary: Start a new workflow instance
|
||||
operationId: startInstance
|
||||
tags: [Workflow Instances]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/idPath'
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
stage_data:
|
||||
type: object
|
||||
responses:
|
||||
'201':
|
||||
description: Instance created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkflowInstance'
|
||||
get:
|
||||
summary: List instances for a workflow
|
||||
operationId: listInstances
|
||||
tags: [Workflow Instances]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/idPath'
|
||||
- name: status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [active, completed, cancelled, stale]
|
||||
- $ref: '#/components/parameters/limitQuery'
|
||||
- $ref: '#/components/parameters/offsetQuery'
|
||||
responses:
|
||||
'200':
|
||||
description: Instance list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WorkflowInstance'
|
||||
|
||||
/api/v1/workflows/{id}/instances/{iid}:
|
||||
get:
|
||||
summary: Get a workflow instance
|
||||
operationId: getInstance
|
||||
tags: [Workflow Instances]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/idPath'
|
||||
- name: iid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Instance details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkflowInstance'
|
||||
|
||||
/api/v1/workflows/{id}/instances/{iid}/advance:
|
||||
post:
|
||||
summary: Advance an instance to the next stage
|
||||
operationId: advanceInstance
|
||||
tags: [Workflow Instances]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/idPath'
|
||||
- name: iid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
stage_data:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Advanced instance
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkflowInstance'
|
||||
|
||||
/api/v1/workflows/{id}/instances/{iid}/cancel:
|
||||
post:
|
||||
summary: Cancel an active instance
|
||||
operationId: cancelInstance
|
||||
tags: [Workflow Instances]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/idPath'
|
||||
- name: iid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Instance cancelled
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkflowInstance'
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Workflow Assignments (v0.3.2)
|
||||
# ──────────────────────────────────────────────
|
||||
/api/v1/assignments/{id}/claim:
|
||||
post:
|
||||
summary: Claim an assignment
|
||||
operationId: claimAssignment
|
||||
tags: [Workflow Assignments]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/idPath'
|
||||
responses:
|
||||
'200':
|
||||
description: Assignment claimed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkflowAssignment'
|
||||
|
||||
/api/v1/assignments/{id}/unclaim:
|
||||
post:
|
||||
summary: Release a claimed assignment
|
||||
operationId: unclaimAssignment
|
||||
tags: [Workflow Assignments]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/idPath'
|
||||
responses:
|
||||
'200':
|
||||
description: Assignment released
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkflowAssignment'
|
||||
|
||||
/api/v1/assignments/{id}/complete:
|
||||
post:
|
||||
summary: Complete an assignment
|
||||
operationId: completeAssignment
|
||||
tags: [Workflow Assignments]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/idPath'
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
review_data:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Assignment completed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkflowAssignment'
|
||||
|
||||
/api/v1/assignments/{id}/cancel:
|
||||
post:
|
||||
summary: Cancel an assignment
|
||||
operationId: cancelAssignment
|
||||
tags: [Workflow Assignments]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/idPath'
|
||||
responses:
|
||||
'200':
|
||||
description: Assignment cancelled
|
||||
|
||||
/api/v1/assignments/mine:
|
||||
get:
|
||||
summary: List my assignments
|
||||
operationId: listMyAssignments
|
||||
tags: [Workflow Assignments]
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [unassigned, claimed, completed, cancelled]
|
||||
responses:
|
||||
'200':
|
||||
description: Assignment list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WorkflowAssignment'
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Workflow Signoffs (v0.3.4)
|
||||
# ──────────────────────────────────────────────
|
||||
/api/v1/instances/{iid}/signoffs:
|
||||
post:
|
||||
summary: Submit a signoff (approve or reject)
|
||||
operationId: submitSignoff
|
||||
tags: [Workflow Signoffs]
|
||||
parameters:
|
||||
- name: iid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [decision]
|
||||
properties:
|
||||
decision:
|
||||
type: string
|
||||
enum: [approve, reject]
|
||||
comment:
|
||||
type: string
|
||||
responses:
|
||||
'201':
|
||||
description: Signoff recorded
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkflowSignoff'
|
||||
get:
|
||||
summary: List signoffs for an instance
|
||||
operationId: listSignoffs
|
||||
tags: [Workflow Signoffs]
|
||||
parameters:
|
||||
- name: iid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Signoff list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/WorkflowSignoff'
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Public Workflows (v0.3.3)
|
||||
# ──────────────────────────────────────────────
|
||||
/api/v1/public/workflows/{id}/start:
|
||||
post:
|
||||
summary: Start a public workflow instance (no auth required)
|
||||
operationId: startPublicInstance
|
||||
tags: [Public Workflows]
|
||||
security: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/idPath'
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
stage_data:
|
||||
type: object
|
||||
responses:
|
||||
'201':
|
||||
description: Public instance created (includes entry_token)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkflowInstance'
|
||||
|
||||
/api/v1/public/workflows/resume/{token}:
|
||||
get:
|
||||
summary: Resume a public instance by entry token
|
||||
operationId: resumePublicInstance
|
||||
tags: [Public Workflows]
|
||||
security: []
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Instance details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkflowInstance'
|
||||
|
||||
/api/v1/public/workflows/advance/{token}:
|
||||
post:
|
||||
summary: Advance a public instance (public-audience stages only)
|
||||
operationId: advancePublicInstance
|
||||
tags: [Public Workflows]
|
||||
security: []
|
||||
parameters:
|
||||
- name: token
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
stage_data:
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Advanced instance
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/WorkflowInstance'
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Team Roles (v0.3.4)
|
||||
# ──────────────────────────────────────────────
|
||||
/api/v1/teams/{teamId}/roles:
|
||||
get:
|
||||
summary: List custom roles for a team
|
||||
operationId: listTeamRoles
|
||||
tags: [Teams]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/teamIdPath'
|
||||
responses:
|
||||
'200':
|
||||
description: Role list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
put:
|
||||
summary: Set custom roles for a team
|
||||
operationId: setTeamRoles
|
||||
tags: [Teams]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/teamIdPath'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Roles updated
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Packages (user-scoped)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@@ -406,9 +406,8 @@ func nullIfEmpty(s string) interface{} {
|
||||
func (s *WorkflowStore) CreateInstance(ctx context.Context, inst *models.WorkflowInstance) error {
|
||||
stageData := jsonOrEmpty(inst.StageData)
|
||||
metadata := jsonOrEmpty(inst.Metadata)
|
||||
status := inst.Status
|
||||
if status == "" {
|
||||
status = models.InstanceStatusActive
|
||||
if inst.Status == "" {
|
||||
inst.Status = models.InstanceStatusActive
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workflow_instances (workflow_id, workflow_version, current_stage,
|
||||
@@ -416,7 +415,7 @@ func (s *WorkflowStore) CreateInstance(ctx context.Context, inst *models.Workflo
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, stage_entered_at, created_at, updated_at`,
|
||||
inst.WorkflowID, inst.WorkflowVersion, inst.CurrentStage,
|
||||
stageData, status, inst.StartedBy, inst.EntryToken, metadata,
|
||||
stageData, inst.Status, inst.StartedBy, inst.EntryToken, metadata,
|
||||
).Scan(&inst.ID, &inst.StageEnteredAt, &inst.CreatedAt, &inst.UpdatedAt)
|
||||
}
|
||||
|
||||
@@ -575,15 +574,14 @@ func (s *WorkflowStore) ListActiveInstances(ctx context.Context) ([]models.Workf
|
||||
|
||||
func (s *WorkflowStore) CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error {
|
||||
reviewData := jsonOrEmpty(a.ReviewData)
|
||||
status := a.Status
|
||||
if status == "" {
|
||||
status = models.AssignmentStatusUnassigned
|
||||
if a.Status == "" {
|
||||
a.Status = models.AssignmentStatusUnassigned
|
||||
}
|
||||
return DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workflow_assignments (instance_id, stage, team_id, assigned_to, status, review_data)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at`,
|
||||
a.InstanceID, a.Stage, a.TeamID, a.AssignedTo, status, reviewData,
|
||||
a.InstanceID, a.Stage, a.TeamID, a.AssignedTo, a.Status, reviewData,
|
||||
).Scan(&a.ID, &a.CreatedAt)
|
||||
}
|
||||
|
||||
|
||||
@@ -161,12 +161,15 @@ function WorkflowEditor({ teamId, workflow, onBack }) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
try {
|
||||
await sw.api.teams.updateWorkflow(teamId, workflow.id, {
|
||||
const patch = {
|
||||
name: form.name.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
entry_mode: form.entry_mode.value,
|
||||
is_active: form.is_active.checked,
|
||||
});
|
||||
};
|
||||
const sth = form.staleness_timeout_hours.value.trim();
|
||||
if (sth !== '') patch.staleness_timeout_hours = parseInt(sth, 10);
|
||||
await sw.api.teams.updateWorkflow(teamId, workflow.id, patch);
|
||||
sw.toast('Workflow updated', 'success');
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
@@ -230,7 +233,13 @@ function WorkflowEditor({ teamId, workflow, onBack }) {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label><input name="description" value=${workflow.description || ''} /></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1;"><label>Description</label><input name="description" value=${workflow.description || ''} /></div>
|
||||
<div class="form-group" style="max-width:180px;"><label>Staleness (hours)</label>
|
||||
<input type="number" name="staleness_timeout_hours" min="0"
|
||||
value=${workflow.staleness_timeout_hours ?? ''} placeholder="disabled" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-label" style="margin:8px 0;">
|
||||
<input type="checkbox" name="is_active" checked=${workflow.is_active !== false} />
|
||||
<span class="toggle-track"></span><span>Active</span>
|
||||
@@ -287,6 +296,9 @@ function StageForm({ stage, teams, onSave, onCancel }) {
|
||||
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
||||
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
||||
const [sla, setSla] = useState(stage?.sla_seconds || '');
|
||||
const [branchRules, setBranchRules] = useState(
|
||||
stage?.branch_rules ? (typeof stage.branch_rules === 'string' ? stage.branch_rules : JSON.stringify(stage.branch_rules, null, 2)) : ''
|
||||
);
|
||||
|
||||
// v0.3.4: stage_config fields
|
||||
const sc = stage?.stage_config ? (typeof stage.stage_config === 'string' ? JSON.parse(stage.stage_config || '{}') : stage.stage_config) : {};
|
||||
@@ -311,6 +323,10 @@ function StageForm({ stage, teams, onSave, onCancel }) {
|
||||
reject_action: valReject || 'cancel',
|
||||
};
|
||||
}
|
||||
let parsedBranch = null;
|
||||
if (branchRules.trim()) {
|
||||
try { parsedBranch = JSON.parse(branchRules); } catch { sw.toast('Invalid branch_rules JSON', 'error'); return; }
|
||||
}
|
||||
onSave({
|
||||
name,
|
||||
stage_mode: mode,
|
||||
@@ -321,6 +337,7 @@ function StageForm({ stage, teams, onSave, onCancel }) {
|
||||
auto_transition: autoTransition,
|
||||
sla_seconds: sla ? parseInt(sla, 10) : null,
|
||||
stage_config: Object.keys(stageConfig).length ? stageConfig : {},
|
||||
branch_rules: parsedBranch || [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -408,6 +425,14 @@ function StageForm({ stage, teams, onSave, onCancel }) {
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
<details style="margin-top:8px;">
|
||||
<summary style="cursor:pointer;font-size:12px;color:var(--text-muted);">Branch Rules (advanced)</summary>
|
||||
<div class="form-group" style="margin-top:4px;">
|
||||
<textarea rows="3" value=${branchRules} onInput=${e => setBranchRules(e.target.value)}
|
||||
placeholder='[{"field":"priority","op":"eq","value":"high","target_stage":"escalation"}]'
|
||||
style="font-family:monospace;font-size:11px;width:100%;"></textarea>
|
||||
</div>
|
||||
</details>
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" checked=${autoTransition} onChange=${e => setAutoTransition(e.target.checked)} />
|
||||
<span class="toggle-track"></span><span>Auto-advance when complete</span>
|
||||
|
||||
Reference in New Issue
Block a user