Feat v0.3.5 settings icd clone (#19)
All checks were successful
CI/CD / detect-changes (push) Successful in 5s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m26s
CI/CD / test-sqlite (push) Successful in 2m36s
CI/CD / build-and-deploy (push) Successful in 1m33s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #19.
This commit is contained in:
2026-03-28 13:32:23 +00:00
committed by xcaliber
parent 0773c86c27
commit d68451fe8e
11 changed files with 1673 additions and 37 deletions

View 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())
}
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)
# ──────────────────────────────────────────────

View File

@@ -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)
}