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>
186 lines
5.1 KiB
Go
186 lines
5.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"armature/store"
|
|
"armature/workflow"
|
|
)
|
|
|
|
// ── Public Workflow Handlers ───────
|
|
|
|
// WorkflowPublicHandler manages unauthenticated workflow entry endpoints.
|
|
type WorkflowPublicHandler struct {
|
|
engine *workflow.Engine
|
|
stores store.Stores
|
|
}
|
|
|
|
// NewWorkflowPublicHandler creates a public workflow handler.
|
|
func NewWorkflowPublicHandler(engine *workflow.Engine, stores store.Stores) *WorkflowPublicHandler {
|
|
return &WorkflowPublicHandler{engine: engine, stores: stores}
|
|
}
|
|
|
|
// publicInstanceResponse strips internal metadata from an instance for public consumption.
|
|
type publicInstanceResponse struct {
|
|
ID string `json:"id"`
|
|
WorkflowID string `json:"workflow_id"`
|
|
CurrentStage string `json:"current_stage"`
|
|
StageData json.RawMessage `json:"stage_data"`
|
|
Status string `json:"status"`
|
|
EntryToken *string `json:"entry_token,omitempty"`
|
|
CreatedAt any `json:"created_at"`
|
|
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) {
|
|
workflowID := c.Param("id")
|
|
|
|
var body struct {
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
|
return
|
|
}
|
|
if len(body.Data) == 0 {
|
|
body.Data = json.RawMessage(`{}`)
|
|
}
|
|
|
|
inst, err := h.engine.StartPublic(c.Request.Context(), workflowID, body.Data)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, publicInstanceResponse{
|
|
ID: inst.ID,
|
|
WorkflowID: inst.WorkflowID,
|
|
CurrentStage: inst.CurrentStage,
|
|
StageData: inst.StageData,
|
|
Status: inst.Status,
|
|
EntryToken: inst.EntryToken,
|
|
CreatedAt: inst.CreatedAt,
|
|
UpdatedAt: inst.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
// ResumePublic returns an active instance by entry token.
|
|
// GET /api/v1/public/workflows/resume/:token
|
|
func (h *WorkflowPublicHandler) ResumePublic(c *gin.Context) {
|
|
token := c.Param("token")
|
|
if token == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
|
|
return
|
|
}
|
|
|
|
inst, err := h.engine.ResumePublic(c.Request.Context(), token)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, publicInstanceResponse{
|
|
ID: inst.ID,
|
|
WorkflowID: inst.WorkflowID,
|
|
CurrentStage: inst.CurrentStage,
|
|
StageData: inst.StageData,
|
|
Status: inst.Status,
|
|
EntryToken: inst.EntryToken,
|
|
CreatedAt: inst.CreatedAt,
|
|
UpdatedAt: inst.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
// AdvancePublic advances a public instance by entry token.
|
|
// POST /api/v1/public/workflows/advance/:token
|
|
func (h *WorkflowPublicHandler) AdvancePublic(c *gin.Context) {
|
|
token := c.Param("token")
|
|
if token == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil && err.Error() != "EOF" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
|
return
|
|
}
|
|
if len(body.Data) == 0 {
|
|
body.Data = json.RawMessage(`{}`)
|
|
}
|
|
|
|
inst, err := h.engine.AdvancePublic(c.Request.Context(), token, body.Data)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "requires authenticated access") {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, publicInstanceResponse{
|
|
ID: inst.ID,
|
|
WorkflowID: inst.WorkflowID,
|
|
CurrentStage: inst.CurrentStage,
|
|
StageData: inst.StageData,
|
|
Status: inst.Status,
|
|
EntryToken: inst.EntryToken,
|
|
CreatedAt: inst.CreatedAt,
|
|
UpdatedAt: inst.UpdatedAt,
|
|
})
|
|
}
|