Fix workflow start, delete guard, package buttons, export, settings CSS
All checks were successful
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-sqlite (pull_request) Successful in 2m52s
CI/CD / test-go-pg (pull_request) Successful in 2m53s
CI/CD / build-and-deploy (pull_request) Successful in 1m17s
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-runners (pull_request) Has been skipped

- Add StartBySlug handler + /api/v1/workflow-entry/:scope/:slug route
  so landing page Start button resolves scope/slug and creates instance
- Guard admin DELETE /api/v1/workflows/:id to reject team-scoped
  workflows (must use team endpoint) preventing accidental global delete
- Hide Delete button for bundled packages (match Export/Update guards)
- Package export uses fetch() with auth + toast on missing assets
- Settings form padding-bottom increased to --sp-12 for save button
- Admin workflow editor: shared StageForm component, public entry URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 21:31:30 +00:00
parent e4f0bdbd36
commit 8d765491ed
11 changed files with 456 additions and 198 deletions

View File

@@ -2,11 +2,14 @@ package handlers
import (
"database/sql"
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"armature/models"
"armature/store"
)
// ── Team-Scoped Workflow Wrappers ────────────────
@@ -39,13 +42,15 @@ func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool {
// ── Adopt Global Workflow ────────────────────
// AdoptTeamWorkflow claims a global (team_id=NULL) workflow for this team.
// AdoptTeamWorkflow clones a global (team_id=NULL) workflow into this team.
// The global original is left untouched so other teams can also adopt it.
// POST /api/v1/teams/:teamId/workflows/:id/adopt
func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
ctx := c.Request.Context()
teamID := c.Param("teamId")
wfID := c.Param("id")
srcID := c.Param("id")
w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID)
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"})
@@ -54,20 +59,78 @@ func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
}
return
}
if w.TeamID != nil {
if src.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"})
// Load stages from the global workflow
stages, err := h.stores.Workflows.ListStages(ctx, srcID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load stages"})
return
}
// Return the updated workflow
c.Set("id", wfID)
h.Get(c)
// Clone the workflow into this team (global original stays untouched)
clone := &models.Workflow{
TeamID: &teamID,
Name: src.Name,
Slug: src.Slug,
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"),
}
if err := h.stores.Workflows.Create(ctx, clone); err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "UNIQUE") {
clone.Slug = src.Slug + "-" + store.NewID()[:6]
if err2 := h.stores.Workflows.Create(ctx, clone); err2 != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow: " + err2.Error()})
return
}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt workflow: " + err.Error()})
return
}
}
// Clone stages
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 {
log.Printf("[workflows] adopt: failed to clone stage %s: %v", st.Name, err)
}
}
clonedStages, _ := h.stores.Workflows.ListStages(ctx, clone.ID)
if clonedStages == nil {
clonedStages = []models.WorkflowStage{}
}
clone.Stages = clonedStages
c.JSON(http.StatusCreated, clone)
}
// ListGlobalWorkflows returns unowned workflows available for adoption.