All checks were successful
CI/CD / detect-changes (pull_request) Successful in 21s
CI/CD / test-frontend (pull_request) Has been skipped
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-sqlite (pull_request) Successful in 2m52s
CI/CD / test-go-pg (pull_request) Successful in 3m6s
CI/CD / build-and-deploy (pull_request) Successful in 1m41s
Packages can declare `adoptable: true` in their manifest. When a team adopts an adoptable package, a team-scoped copy is created that references the original via `adopted_from` (shared assets, no disk duplication). The package's `requires_roles` auto-populate into a new `team_role_catalog` table so team admins know which roles to assign. Migration 017: adoptable/adopted_from columns on packages, team_role_catalog table. 4 new endpoints (adopt, list adoptable, unadopt, role catalog). AdoptTeamWorkflow deprecated. 11 new tests, all passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
284 lines
8.7 KiB
Go
284 lines
8.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"database/sql"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"armature/models"
|
|
"armature/store"
|
|
)
|
|
|
|
// ── Team-Scoped Workflow Wrappers ────────────────
|
|
//
|
|
// Thin wrappers that enforce team ownership before delegating
|
|
// to the existing WorkflowHandler methods. Follows the same
|
|
// pattern as CreateTeamTask / ListTeamTasks.
|
|
|
|
// requireTeamWorkflow loads the workflow by :id and verifies
|
|
// its team_id matches :teamId. Returns false (and writes an
|
|
// HTTP error) if the check fails; true if the caller may proceed.
|
|
func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool {
|
|
teamID := c.Param("teamId")
|
|
wfID := c.Param("id")
|
|
w, err := h.stores.Workflows.GetByID(c.Request.Context(), wfID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workflow"})
|
|
}
|
|
return false
|
|
}
|
|
if w.TeamID == nil || *w.TeamID != teamID {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "workflow does not belong to this team"})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ── Adopt Global Workflow ────────────────────
|
|
|
|
// 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
|
|
//
|
|
// Deprecated: Use POST /api/v1/teams/:teamId/packages/:id/adopt instead.
|
|
// This endpoint will be removed in a future version.
|
|
func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
|
|
c.Header("X-Deprecated", "Use POST /api/v1/teams/:teamId/packages/:id/adopt instead")
|
|
log.Printf("[workflows] DEPRECATED: AdoptTeamWorkflow called for team %s — use POST /teams/:teamId/packages/:id/adopt", c.Param("teamId"))
|
|
|
|
ctx := c.Request.Context()
|
|
teamID := c.Param("teamId")
|
|
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"})
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load workflow"})
|
|
}
|
|
return
|
|
}
|
|
if src.TeamID != nil {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "workflow already belongs to a team"})
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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.
|
|
// 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.
|
|
// GET /api/v1/teams/:teamId/workflows
|
|
func (h *WorkflowHandler) ListTeamWorkflows(c *gin.Context) {
|
|
teamID := c.Param("teamId")
|
|
result, err := h.stores.Workflows.ListForTeam(c.Request.Context(), teamID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"})
|
|
return
|
|
}
|
|
if result == nil {
|
|
result = []models.Workflow{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
|
}
|
|
|
|
// CreateTeamWorkflow creates a workflow scoped to the team.
|
|
// POST /api/v1/teams/:teamId/workflows
|
|
func (h *WorkflowHandler) CreateTeamWorkflow(c *gin.Context) {
|
|
teamID := c.Param("teamId")
|
|
c.Set("force_team_id", teamID)
|
|
h.Create(c)
|
|
}
|
|
|
|
// GetTeamWorkflow returns a team workflow with stages.
|
|
// GET /api/v1/teams/:teamId/workflows/:id
|
|
func (h *WorkflowHandler) GetTeamWorkflow(c *gin.Context) {
|
|
if !h.requireTeamWorkflow(c) {
|
|
return
|
|
}
|
|
h.Get(c)
|
|
}
|
|
|
|
// UpdateTeamWorkflow patches a team workflow.
|
|
// PATCH /api/v1/teams/:teamId/workflows/:id
|
|
func (h *WorkflowHandler) UpdateTeamWorkflow(c *gin.Context) {
|
|
if !h.requireTeamWorkflow(c) {
|
|
return
|
|
}
|
|
h.Update(c)
|
|
}
|
|
|
|
// DeleteTeamWorkflow deletes a team workflow.
|
|
// DELETE /api/v1/teams/:teamId/workflows/:id
|
|
func (h *WorkflowHandler) DeleteTeamWorkflow(c *gin.Context) {
|
|
if !h.requireTeamWorkflow(c) {
|
|
return
|
|
}
|
|
h.Delete(c)
|
|
}
|
|
|
|
// ── Stage CRUD ──────────────────────────────
|
|
|
|
// ListTeamWorkflowStages returns stages for a team workflow.
|
|
// GET /api/v1/teams/:teamId/workflows/:id/stages
|
|
func (h *WorkflowHandler) ListTeamWorkflowStages(c *gin.Context) {
|
|
if !h.requireTeamWorkflow(c) {
|
|
return
|
|
}
|
|
h.ListStages(c)
|
|
}
|
|
|
|
// CreateTeamWorkflowStage adds a stage to a team workflow.
|
|
// POST /api/v1/teams/:teamId/workflows/:id/stages
|
|
func (h *WorkflowHandler) CreateTeamWorkflowStage(c *gin.Context) {
|
|
if !h.requireTeamWorkflow(c) {
|
|
return
|
|
}
|
|
h.CreateStage(c)
|
|
}
|
|
|
|
// UpdateTeamWorkflowStage updates a stage on a team workflow.
|
|
// PUT /api/v1/teams/:teamId/workflows/:id/stages/:sid
|
|
func (h *WorkflowHandler) UpdateTeamWorkflowStage(c *gin.Context) {
|
|
if !h.requireTeamWorkflow(c) {
|
|
return
|
|
}
|
|
h.UpdateStage(c)
|
|
}
|
|
|
|
// DeleteTeamWorkflowStage removes a stage from a team workflow.
|
|
// DELETE /api/v1/teams/:teamId/workflows/:id/stages/:sid
|
|
func (h *WorkflowHandler) DeleteTeamWorkflowStage(c *gin.Context) {
|
|
if !h.requireTeamWorkflow(c) {
|
|
return
|
|
}
|
|
h.DeleteStage(c)
|
|
}
|
|
|
|
// ReorderTeamWorkflowStages reorders stages on a team workflow.
|
|
// PATCH /api/v1/teams/:teamId/workflows/:id/stages/reorder
|
|
func (h *WorkflowHandler) ReorderTeamWorkflowStages(c *gin.Context) {
|
|
if !h.requireTeamWorkflow(c) {
|
|
return
|
|
}
|
|
h.ReorderStages(c)
|
|
}
|
|
|
|
// ── Publish / Version ───────────────────────
|
|
|
|
// PublishTeamWorkflow publishes a team workflow version.
|
|
// POST /api/v1/teams/:teamId/workflows/:id/publish
|
|
func (h *WorkflowHandler) PublishTeamWorkflow(c *gin.Context) {
|
|
if !h.requireTeamWorkflow(c) {
|
|
return
|
|
}
|
|
h.Publish(c)
|
|
}
|
|
|
|
// GetTeamWorkflowVersion returns a version snapshot for a team workflow.
|
|
// GET /api/v1/teams/:teamId/workflows/:id/versions/:version
|
|
func (h *WorkflowHandler) GetTeamWorkflowVersion(c *gin.Context) {
|
|
if !h.requireTeamWorkflow(c) {
|
|
return
|
|
}
|
|
h.GetVersion(c)
|
|
}
|
|
|
|
// 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)
|
|
}
|