Feat v0.3.3 public entry background jobs (#17)
Some checks failed
CI/CD / detect-changes (push) Successful in 21s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-go-pg (push) Failing after 2m24s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Has been skipped

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #17.
This commit is contained in:
2026-03-27 23:07:55 +00:00
committed by xcaliber
parent ab28e4b784
commit dba718b914
14 changed files with 743 additions and 40 deletions

View File

@@ -0,0 +1,140 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"switchboard-core/store"
"switchboard-core/workflow"
)
// ── Public Workflow Handlers (v0.3.3) ───────
// 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"`
}
// 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,
})
}

View File

@@ -587,6 +587,137 @@ func TestWorkflowAssignment_Cancel(t *testing.T) {
}
}
// ── v0.3.3 Store Tests ──────────────────────
func TestWorkflowInstance_MarkStale(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Stale-Test", "stale@test.com")
teamID := database.SeedTestTeam(t, "Team Stale", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
inst := &models.WorkflowInstance{
WorkflowID: wfID, WorkflowVersion: ver,
CurrentStage: stage1, StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
if err := s.Workflows.MarkInstanceStale(ctx, inst.ID); err != nil {
t.Fatalf("mark stale: %v", err)
}
got, _ := s.Workflows.GetInstance(ctx, inst.ID)
if got.Status != models.InstanceStatusStale {
t.Errorf("status = %q, want stale", got.Status)
}
// Marking again is a no-op (WHERE status='active' won't match)
if err := s.Workflows.MarkInstanceStale(ctx, inst.ID); err != nil {
t.Fatalf("mark stale again: %v", err)
}
}
func TestWorkflowInstance_ListActive(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Active-Test", "active@test.com")
teamID := database.SeedTestTeam(t, "Team Active", userID)
wfID, ver, stage1, _ := seedWorkflowFixture(t, s, userID, teamID)
// Create 3 instances: 2 active, 1 completed
var activeIDs []string
for i := 0; i < 3; i++ {
inst := &models.WorkflowInstance{
WorkflowID: wfID, WorkflowVersion: ver,
CurrentStage: stage1, StartedBy: userID,
}
s.Workflows.CreateInstance(ctx, inst)
if i < 2 {
activeIDs = append(activeIDs, inst.ID)
} else {
s.Workflows.CompleteInstance(ctx, inst.ID)
}
}
active, err := s.Workflows.ListActiveInstances(ctx)
if err != nil {
t.Fatalf("list active: %v", err)
}
if len(active) != 2 {
t.Fatalf("count = %d, want 2", len(active))
}
for _, inst := range active {
if inst.Status != models.InstanceStatusActive {
t.Errorf("instance %s status = %q, want active", inst.ID, inst.Status)
}
}
}
func TestWorkflow_StalenessTimeoutHours(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Staleness-Test", "staleness@test.com")
teamID := database.SeedTestTeam(t, "Team Staleness", userID)
// Create with staleness_timeout_hours set
hours := 48
wf := &models.Workflow{
TeamID: &teamID,
Name: "Staleness WF",
Slug: "staleness-wf",
EntryMode: "team_only",
IsActive: true,
CreatedBy: userID,
StalenessTimeoutHours: &hours,
}
if err := s.Workflows.Create(ctx, wf); err != nil {
t.Fatalf("create: %v", err)
}
got, err := s.Workflows.GetByID(ctx, wf.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.StalenessTimeoutHours == nil || *got.StalenessTimeoutHours != 48 {
t.Errorf("staleness_timeout_hours = %v, want 48", got.StalenessTimeoutHours)
}
// Update via patch
newHours := 24
if err := s.Workflows.Update(ctx, wf.ID, models.WorkflowPatch{StalenessTimeoutHours: &newHours}); err != nil {
t.Fatalf("update: %v", err)
}
got2, _ := s.Workflows.GetByID(ctx, wf.ID)
if got2.StalenessTimeoutHours == nil || *got2.StalenessTimeoutHours != 24 {
t.Errorf("after update staleness_timeout_hours = %v, want 24", got2.StalenessTimeoutHours)
}
// Create without staleness — should be nil
wf2 := &models.Workflow{
TeamID: &teamID,
Name: "No Staleness WF",
Slug: "no-staleness-wf",
EntryMode: "team_only",
IsActive: true,
CreatedBy: userID,
}
s.Workflows.Create(ctx, wf2)
got3, _ := s.Workflows.GetByID(ctx, wf2.ID)
if got3.StalenessTimeoutHours != nil {
t.Errorf("nil staleness = %v, want nil", got3.StalenessTimeoutHours)
}
}
func TestWorkflowAssignment_ListByTeam(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)