Feat v0.7.8 bug fixes admin gaps (#62)
All checks were successful
CI/CD / detect-changes (push) Successful in 5s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 29s
CI/CD / test-sqlite (push) Successful in 2m55s
All checks were successful
CI/CD / detect-changes (push) Successful in 5s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 29s
CI/CD / test-sqlite (push) Successful in 2m55s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #62.
This commit is contained in:
@@ -1183,9 +1183,16 @@ func (h *PackageHandler) ExportPackage(c *gin.Context) {
|
||||
|
||||
// Walk packagesDir/{id}/ and add all asset files
|
||||
if h.packagesDir == "" {
|
||||
log.Printf("[packages] export: packagesDir not set, exporting manifest only for %s", pkgID)
|
||||
c.Header("X-Export-Warning", "no-assets")
|
||||
return
|
||||
}
|
||||
pkgDir := filepath.Join(h.packagesDir, pkgID)
|
||||
if _, statErr := os.Stat(pkgDir); os.IsNotExist(statErr) {
|
||||
log.Printf("[packages] export: asset directory missing for %s, exporting manifest only", pkgID)
|
||||
c.Header("X-Export-Warning", "no-assets")
|
||||
return
|
||||
}
|
||||
filepath.Walk(pkgDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
|
||||
@@ -36,6 +36,51 @@ type publicInstanceResponse struct {
|
||||
UpdatedAt any `json:"updated_at"`
|
||||
}
|
||||
|
||||
// StartBySlug resolves a scope+slug to a workflow and starts a public instance.
|
||||
// POST /api/v1/workflow-entry/:scope/:slug
|
||||
// Called by the workflow landing page (/w/:scope/:slug).
|
||||
func (h *WorkflowPublicHandler) StartBySlug(c *gin.Context) {
|
||||
scope := c.Param("scope")
|
||||
slug := c.Param("slug")
|
||||
|
||||
var teamID *string
|
||||
if scope != "global" {
|
||||
teamID = &scope
|
||||
}
|
||||
|
||||
wf, err := h.stores.Workflows.GetBySlug(c.Request.Context(), teamID, slug)
|
||||
if err != nil || wf == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" {
|
||||
body.Data = json.RawMessage(`{}`)
|
||||
}
|
||||
if len(body.Data) == 0 {
|
||||
body.Data = json.RawMessage(`{}`)
|
||||
}
|
||||
|
||||
inst, err := h.engine.StartPublic(c.Request.Context(), wf.ID, body.Data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Return redirect for the landing page JS
|
||||
redirectTo := "/w/" + inst.ID
|
||||
if inst.EntryToken != nil {
|
||||
redirectTo = "/w/" + inst.ID + "?token=" + *inst.EntryToken
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": inst.ID,
|
||||
"redirect_to": redirectTo,
|
||||
})
|
||||
}
|
||||
|
||||
// StartPublic creates an anonymous workflow instance.
|
||||
// POST /api/v1/public/workflows/:id/start
|
||||
func (h *WorkflowPublicHandler) StartPublic(c *gin.Context) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -149,8 +149,24 @@ func (h *WorkflowHandler) Update(c *gin.Context) {
|
||||
|
||||
// Delete deletes a workflow and all its stages/versions (CASCADE).
|
||||
// DELETE /api/v1/workflows/:id
|
||||
// Admin-only: only allows deleting global (team_id IS NULL) workflows.
|
||||
// Team-scoped workflows must be deleted via the team endpoint.
|
||||
func (h *WorkflowHandler) Delete(c *gin.Context) {
|
||||
if err := h.stores.Workflows.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
ctx := c.Request.Context()
|
||||
wfID := c.Param("id")
|
||||
|
||||
// Guard: prevent accidental deletion of team-scoped workflows from the admin endpoint
|
||||
wf, err := h.stores.Workflows.GetByID(ctx, wfID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"})
|
||||
return
|
||||
}
|
||||
if wf.TeamID != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "team-scoped workflows must be deleted via the team endpoint"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Workflows.Delete(ctx, wfID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete workflow"})
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user