Feat v0.3.7 package audit (#20)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #20.
This commit is contained in:
232
server/handlers/package_dormant_test.go
Normal file
232
server/handlers/package_dormant_test.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
authpkg "switchboard-core/auth"
|
||||
"switchboard-core/config"
|
||||
"switchboard-core/database"
|
||||
"switchboard-core/middleware"
|
||||
"switchboard-core/store"
|
||||
postgres "switchboard-core/store/postgres"
|
||||
sqlite "switchboard-core/store/sqlite"
|
||||
)
|
||||
|
||||
// ── Dormant Test Harness ──────────────────────
|
||||
|
||||
type dormantHarness struct {
|
||||
*testHarness
|
||||
adminToken string
|
||||
adminID string
|
||||
stores store.Stores
|
||||
pkgH *PackageHandler
|
||||
}
|
||||
|
||||
func setupDormantHarness(t *testing.T) *dormantHarness {
|
||||
t.Helper()
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: testJWTSecret,
|
||||
BasePath: "",
|
||||
}
|
||||
|
||||
var stores store.Stores
|
||||
if database.IsSQLite() {
|
||||
stores = sqlite.NewStores(database.TestDB)
|
||||
} else {
|
||||
stores = postgres.NewStores(database.TestDB)
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
|
||||
auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
|
||||
api.POST("/auth/register", auth.Register)
|
||||
|
||||
pkgH := NewPackageHandler(stores)
|
||||
|
||||
// Protected (authenticated) routes
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
|
||||
|
||||
// Admin routes
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin(stores))
|
||||
admin.GET("/packages", pkgH.ListPackages)
|
||||
admin.PUT("/packages/:id/enable", pkgH.EnablePackage)
|
||||
admin.PUT("/packages/:id/disable", pkgH.DisablePackage)
|
||||
|
||||
// Seed admin user
|
||||
adminID := seedInsertReturningID(t,
|
||||
`INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
"dorm-admin", "dorm-admin@test.com", "$2a$10$test", "dorm-admin", "builtin",
|
||||
)
|
||||
database.SeedEveryoneGroupMember(t, adminID)
|
||||
database.SeedAdminsGroupMember(t, adminID)
|
||||
adminToken := makeToken(adminID, "dorm-admin@test.com", "admin")
|
||||
|
||||
return &dormantHarness{
|
||||
testHarness: &testHarness{router: r, t: t},
|
||||
adminToken: adminToken,
|
||||
adminID: adminID,
|
||||
stores: stores,
|
||||
pkgH: pkgH,
|
||||
}
|
||||
}
|
||||
|
||||
// seedPackage inserts a package directly into the DB for testing.
|
||||
func (h *dormantHarness) seedPackage(id, title, pkgType, status string, enabled bool, manifest map[string]any) {
|
||||
h.t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
manifestJSON, _ := json.Marshal(manifest)
|
||||
|
||||
q := dialectSQL(`INSERT INTO packages (id, title, type, version, tier, manifest, enabled, status, source)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`)
|
||||
|
||||
enabledInt := 0
|
||||
if enabled {
|
||||
enabledInt = 1
|
||||
}
|
||||
|
||||
_, err := database.TestDB.ExecContext(ctx, q,
|
||||
id, title, pkgType, "1.0.0", "browser", string(manifestJSON), enabledInt, status, "extension",
|
||||
)
|
||||
if err != nil {
|
||||
h.t.Fatalf("seedPackage(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// Tests
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
// TestSetStatus_Dormant verifies the DB CHECK constraint accepts 'dormant'.
|
||||
func TestSetStatus_Dormant(t *testing.T) {
|
||||
h := setupDormantHarness(t)
|
||||
|
||||
h.seedPackage("status-test", "Status Test", "surface", "active", true, map[string]any{
|
||||
"id": "status-test", "title": "Status Test", "type": "surface",
|
||||
})
|
||||
|
||||
err := h.stores.Packages.SetStatus(context.Background(), "status-test", "dormant")
|
||||
if err != nil {
|
||||
t.Fatalf("SetStatus(dormant) failed: %v", err)
|
||||
}
|
||||
|
||||
pkg, err := h.stores.Packages.Get(context.Background(), "status-test")
|
||||
if err != nil || pkg == nil {
|
||||
t.Fatalf("Get after SetStatus failed: %v", err)
|
||||
}
|
||||
if pkg.Status != "dormant" {
|
||||
t.Errorf("status = %q, want %q", pkg.Status, "dormant")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnableDormant_Blocked verifies the enable endpoint rejects dormant packages.
|
||||
func TestEnableDormant_Blocked(t *testing.T) {
|
||||
h := setupDormantHarness(t)
|
||||
|
||||
h.seedPackage("dormant-ext", "Dormant Extension", "extension", "dormant", false, map[string]any{
|
||||
"id": "dormant-ext", "title": "Dormant Extension", "type": "extension", "requires": []string{"chat"},
|
||||
})
|
||||
|
||||
w := h.request("PUT", "/api/v1/admin/packages/dormant-ext/enable", h.adminToken, nil)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("enable dormant: want 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
decode(t, w, &resp)
|
||||
errMsg, _ := resp["error"].(string)
|
||||
if errMsg == "" {
|
||||
t.Fatal("expected error message in response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDormantNotInSurfaces verifies dormant packages are excluded from the surfaces list.
|
||||
func TestDormantNotInSurfaces(t *testing.T) {
|
||||
h := setupDormantHarness(t)
|
||||
|
||||
// Active surface — should appear
|
||||
h.seedPackage("active-surface", "Active Surface", "surface", "active", true, map[string]any{
|
||||
"id": "active-surface", "title": "Active Surface", "type": "surface",
|
||||
"route": "/s/active-surface",
|
||||
})
|
||||
// Dormant surface — should NOT appear
|
||||
h.seedPackage("dormant-surface", "Dormant Surface", "full", "dormant", false, map[string]any{
|
||||
"id": "dormant-surface", "title": "Dormant Surface", "type": "full",
|
||||
"route": "/s/dormant-surface", "requires": []string{"chat"},
|
||||
})
|
||||
|
||||
w := h.request("GET", "/api/v1/surfaces", h.adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("surfaces list: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
decode(t, w, &resp)
|
||||
data, _ := resp["data"].([]interface{})
|
||||
|
||||
for _, item := range data {
|
||||
m, _ := item.(map[string]interface{})
|
||||
if m["id"] == "dormant-surface" {
|
||||
t.Fatal("dormant surface should not appear in surfaces list")
|
||||
}
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, item := range data {
|
||||
m, _ := item.(map[string]interface{})
|
||||
if m["id"] == "active-surface" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("active surface should appear in surfaces list")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDormantInAdminList verifies dormant packages still appear in admin package list.
|
||||
func TestDormantInAdminList(t *testing.T) {
|
||||
h := setupDormantHarness(t)
|
||||
|
||||
h.seedPackage("vis-active", "Visible Active", "surface", "active", true, map[string]any{
|
||||
"id": "vis-active", "title": "Visible Active", "type": "surface",
|
||||
})
|
||||
h.seedPackage("vis-dormant", "Visible Dormant", "extension", "dormant", false, map[string]any{
|
||||
"id": "vis-dormant", "title": "Visible Dormant", "type": "extension", "requires": []string{"chat"},
|
||||
})
|
||||
|
||||
w := h.request("GET", "/api/v1/admin/packages", h.adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin list: want 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
decode(t, w, &resp)
|
||||
data, _ := resp["data"].([]interface{})
|
||||
|
||||
ids := map[string]bool{}
|
||||
for _, item := range data {
|
||||
m, _ := item.(map[string]interface{})
|
||||
id, _ := m["id"].(string)
|
||||
ids[id] = true
|
||||
}
|
||||
|
||||
if !ids["vis-active"] {
|
||||
t.Error("active package missing from admin list")
|
||||
}
|
||||
if !ids["vis-dormant"] {
|
||||
t.Error("dormant package missing from admin list")
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,20 @@ func (h *PackageHandler) GetPackage(c *gin.Context) {
|
||||
// PUT /api/v1/admin/surfaces/:id/enable (alias)
|
||||
func (h *PackageHandler) EnablePackage(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
// v0.3.7: Block enabling dormant packages (unmet requires).
|
||||
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
||||
if err != nil || pkg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||
return
|
||||
}
|
||||
if pkg.Status == "dormant" {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "package is dormant — it requires capabilities not yet available (e.g. chat)",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||
return
|
||||
@@ -533,8 +547,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
|
||||
return
|
||||
}
|
||||
if lib.Status != "active" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not active: " + libID})
|
||||
if lib.Status == "suspended" || lib.Status == "dormant" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is " + lib.Status + ": " + libID})
|
||||
return
|
||||
}
|
||||
versionSpec, _ := vSpec.(string)
|
||||
@@ -569,14 +583,38 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
// v0.3.7: Auto-set dormant for packages with unmet requires.
|
||||
// Known capabilities: (none yet — chat and legacy-sdk are future/removed).
|
||||
knownCaps := map[string]bool{}
|
||||
var unmetReqs []string
|
||||
if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
|
||||
for _, r := range reqs {
|
||||
req, _ := r.(string)
|
||||
if req != "" && !knownCaps[req] {
|
||||
unmetReqs = append(unmetReqs, req)
|
||||
}
|
||||
}
|
||||
}
|
||||
isDormant := len(unmetReqs) > 0
|
||||
if isDormant {
|
||||
h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
|
||||
h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
|
||||
preservedEnabled = false
|
||||
log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
|
||||
}
|
||||
|
||||
resp := gin.H{
|
||||
"id": pkgID,
|
||||
"title": title,
|
||||
"type": pkgType,
|
||||
"version": version,
|
||||
"source": pkgSource,
|
||||
"enabled": preservedEnabled,
|
||||
})
|
||||
}
|
||||
if isDormant {
|
||||
resp["status"] = "dormant"
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// extractableRelPath returns the relative path for a zip entry if it
|
||||
|
||||
@@ -468,3 +468,162 @@ func TestEngine_ErrorCases(t *testing.T) {
|
||||
t.Fatalf("error = %q, want 'public entry'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Automated Stage Context (started_by) ────
|
||||
|
||||
func TestEngine_AutomatedStageContextIncludesStartedBy(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
s := testStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "AutoCtx", "autoctx@test.com")
|
||||
teamID := database.SeedTestTeam(t, "AutoCtx Team", userID)
|
||||
|
||||
wf := &models.Workflow{
|
||||
TeamID: &teamID, Name: "AutoCtx WF", Slug: "autoctx-wf",
|
||||
EntryMode: "team_only", IsActive: true, CreatedBy: userID,
|
||||
}
|
||||
s.Workflows.Create(ctx, wf)
|
||||
|
||||
hook := "test-pkg:on_run"
|
||||
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: "auto-stage", StageMode: models.StageModeAutomated, Audience: models.AudienceSystem, StageType: models.StageTypeAutomated, AutoTransition: true, StarlarkHook: &hook},
|
||||
{WorkflowID: wf.ID, Ordinal: 2, 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})
|
||||
|
||||
// Engine with no runner — automated stage silently skips, instance stays at auto-stage
|
||||
eng := workflow.NewEngine(s, nil, nil)
|
||||
inst, err := eng.Start(ctx, wf.ID, json.RawMessage(`{}`), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
// Advance intake → auto-stage
|
||||
inst, err = eng.Advance(ctx, inst.ID, json.RawMessage(`{"submitted":true}`), userID)
|
||||
if err != nil {
|
||||
t.Fatalf("advance to auto: %v", err)
|
||||
}
|
||||
|
||||
// Verify the instance records started_by
|
||||
got, _ := s.Workflows.GetInstance(ctx, inst.ID)
|
||||
if got.StartedBy != userID {
|
||||
t.Fatalf("started_by = %q, want %q", got.StartedBy, userID)
|
||||
}
|
||||
}
|
||||
|
||||
// ── SLA Seconds in Package Install ──────────
|
||||
|
||||
func TestPackageInstall_SLASeconds(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
s := testStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "SLAPkg", "slapkg@test.com")
|
||||
teamID := database.SeedTestTeam(t, "SLA Team", userID)
|
||||
|
||||
// Simulate workflow package install with sla_seconds
|
||||
wf := &models.Workflow{
|
||||
TeamID: &teamID, Name: "SLA WF", Slug: "sla-wf",
|
||||
EntryMode: "team_only", IsActive: true, CreatedBy: userID,
|
||||
}
|
||||
s.Workflows.Create(ctx, wf)
|
||||
|
||||
sla := 3600
|
||||
stage := &models.WorkflowStage{
|
||||
WorkflowID: wf.ID, Ordinal: 0, Name: "critical-fix",
|
||||
StageMode: models.StageModeForm, Audience: models.AudienceTeam,
|
||||
StageType: models.StageTypeSimple, SLASeconds: &sla,
|
||||
}
|
||||
if err := s.Workflows.CreateStage(ctx, stage); err != nil {
|
||||
t.Fatalf("create stage: %v", err)
|
||||
}
|
||||
|
||||
// Read it back
|
||||
stages, err := s.Workflows.ListStages(ctx, wf.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list stages: %v", err)
|
||||
}
|
||||
if len(stages) != 1 {
|
||||
t.Fatalf("got %d stages, want 1", len(stages))
|
||||
}
|
||||
if stages[0].SLASeconds == nil || *stages[0].SLASeconds != 3600 {
|
||||
t.Fatalf("sla_seconds = %v, want 3600", stages[0].SLASeconds)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workflow Package Manifest Install ────────
|
||||
|
||||
func TestPackageInstall_ManifestRoundtrip(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
s := testStores(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := database.SeedTestUser(t, "PkgInst", "pkginst@test.com")
|
||||
_ = database.SeedTestTeam(t, "PkgInst Team", userID)
|
||||
|
||||
// Create a workflow manually matching bug-report-triage structure
|
||||
wf := &models.Workflow{
|
||||
Name: "Bug Report Triage", Slug: "bug-report-triage-test",
|
||||
EntryMode: "public_link", IsActive: true, CreatedBy: userID,
|
||||
}
|
||||
if err := s.Workflows.Create(ctx, wf); err != nil {
|
||||
t.Fatalf("create workflow: %v", err)
|
||||
}
|
||||
|
||||
sla := 3600
|
||||
branchRules, _ := json.Marshal([]map[string]any{
|
||||
{"field": "severity", "op": "eq", "value": "critical", "target_stage": "fix-critical"},
|
||||
{"field": "severity", "op": "neq", "value": "critical", "target_stage": "fix-normal"},
|
||||
})
|
||||
|
||||
stages := []models.WorkflowStage{
|
||||
{WorkflowID: wf.ID, Ordinal: 0, Name: "submit", StageMode: models.StageModeForm, Audience: models.AudiencePublic, StageType: models.StageTypeSimple},
|
||||
{WorkflowID: wf.ID, Ordinal: 1, Name: "classify", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, BranchRules: branchRules},
|
||||
{WorkflowID: wf.ID, Ordinal: 2, Name: "fix-critical", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple, SLASeconds: &sla},
|
||||
{WorkflowID: wf.ID, Ordinal: 3, Name: "fix-normal", StageMode: models.StageModeForm, Audience: models.AudienceTeam, StageType: models.StageTypeSimple},
|
||||
{WorkflowID: wf.ID, Ordinal: 4, Name: "verify", StageMode: models.StageModeReview, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all stages
|
||||
got, err := s.Workflows.ListStages(ctx, wf.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list stages: %v", err)
|
||||
}
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("got %d stages, want 5", len(got))
|
||||
}
|
||||
|
||||
// Verify branch rules on classify
|
||||
for _, g := range got {
|
||||
if g.Name == "classify" {
|
||||
if len(g.BranchRules) == 0 {
|
||||
t.Fatal("classify missing branch_rules")
|
||||
}
|
||||
if !strings.Contains(string(g.BranchRules), "fix-critical") {
|
||||
t.Fatalf("branch_rules missing fix-critical target: %s", string(g.BranchRules))
|
||||
}
|
||||
}
|
||||
if g.Name == "fix-critical" {
|
||||
if g.SLASeconds == nil || *g.SLASeconds != 3600 {
|
||||
t.Fatalf("fix-critical sla_seconds = %v, want 3600", g.SLASeconds)
|
||||
}
|
||||
}
|
||||
if g.Name == "submit" && g.Audience != models.AudiencePublic {
|
||||
t.Fatalf("submit audience = %q, want public", g.Audience)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ func (h *WorkflowPackageHandler) ExportWorkflowPackage(c *gin.Context) {
|
||||
if s.StarlarkHook != nil {
|
||||
sd["starlark_hook"] = *s.StarlarkHook
|
||||
}
|
||||
if s.SLASeconds != nil {
|
||||
sd["sla_seconds"] = *s.SLASeconds
|
||||
}
|
||||
if len(s.FormTemplate) > 0 && string(s.FormTemplate) != "{}" {
|
||||
var ft any
|
||||
if json.Unmarshal(s.FormTemplate, &ft) == nil {
|
||||
@@ -225,6 +228,7 @@ func InstallWorkflowFromManifest(ctx *gin.Context, stores store.Stores, pkgID st
|
||||
AssignmentTeamID: s.AssignmentTeamID,
|
||||
SurfacePkgID: s.SurfacePkgID,
|
||||
StarlarkHook: s.StarlarkHook,
|
||||
SLASeconds: s.SLASeconds,
|
||||
}
|
||||
if st.StageMode == "" {
|
||||
st.StageMode = models.StageModeDelegated
|
||||
@@ -268,6 +272,7 @@ type workflowPkgStage struct {
|
||||
AssignmentTeamID *string `json:"assignment_team_id,omitempty"`
|
||||
SurfacePkgID *string `json:"surface_pkg_id,omitempty"`
|
||||
StarlarkHook *string `json:"starlark_hook,omitempty"`
|
||||
SLASeconds *int `json:"sla_seconds,omitempty"`
|
||||
FormTemplate any `json:"form_template,omitempty"`
|
||||
StageConfig any `json:"stage_config,omitempty"`
|
||||
BranchRules any `json:"branch_rules,omitempty"`
|
||||
|
||||
@@ -37,6 +37,53 @@ func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Adopt Global Workflow ────────────────────
|
||||
|
||||
// AdoptTeamWorkflow claims a global (team_id=NULL) workflow for this team.
|
||||
// POST /api/v1/teams/:teamId/workflows/:id/adopt
|
||||
func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
wfID := c.Param("id")
|
||||
|
||||
w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workflow"})
|
||||
}
|
||||
return
|
||||
}
|
||||
if w.TeamID != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "workflow already belongs to a team"})
|
||||
return
|
||||
}
|
||||
|
||||
patch := models.WorkflowPatch{TeamID: &teamID}
|
||||
if err := h.stores.Workflows.Update(c.Request.Context(), wfID, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return the updated workflow
|
||||
c.Set("id", wfID)
|
||||
h.Get(c)
|
||||
}
|
||||
|
||||
// ListGlobalWorkflows returns unowned workflows available for adoption.
|
||||
// GET /api/v1/teams/:teamId/workflows/available
|
||||
func (h *WorkflowHandler) ListGlobalWorkflows(c *gin.Context) {
|
||||
result, err := h.stores.Workflows.ListGlobal(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"})
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
result = []models.Workflow{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
// ── Workflow CRUD ───────────────────────────
|
||||
|
||||
// ListTeamWorkflows returns workflows owned by the team.
|
||||
|
||||
Reference in New Issue
Block a user