Feat v0.3.4 team roles signoff (#18)
Some checks failed
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Failing after 2m45s
CI/CD / test-sqlite (push) Successful in 2m49s
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 #18.
This commit is contained in:
2026-03-28 01:15:33 +00:00
committed by xcaliber
parent dba718b914
commit 0773c86c27
24 changed files with 1039 additions and 20 deletions

View File

@@ -22,8 +22,7 @@ CREATE TABLE IF NOT EXISTS team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member'
CHECK (role IN ('admin', 'member')),
role VARCHAR(50) NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(team_id, user_id)
);

View File

@@ -131,3 +131,19 @@ CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status
ON workflow_assignments(team_id, status);
CREATE INDEX IF NOT EXISTS idx_workflow_assignments_assigned
ON workflow_assignments(assigned_to) WHERE assigned_to IS NOT NULL;
-- ── Workflow Signoffs (v0.3.4) ────────────────
CREATE TABLE IF NOT EXISTS workflow_signoffs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE,
stage TEXT NOT NULL,
user_id TEXT NOT NULL,
decision TEXT NOT NULL CHECK (decision IN ('approve', 'reject')),
comment TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(instance_id, stage, user_id)
);
CREATE INDEX IF NOT EXISTS idx_workflow_signoffs_instance_stage
ON workflow_signoffs(instance_id, stage);

View File

@@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS team_members (
id TEXT PRIMARY KEY,
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')),
role TEXT NOT NULL DEFAULT 'member',
joined_at TEXT DEFAULT (datetime('now')),
UNIQUE(team_id, user_id)
);

View File

@@ -120,3 +120,19 @@ CREATE INDEX IF NOT EXISTS idx_workflow_assignments_team_status
ON workflow_assignments(team_id, status);
CREATE INDEX IF NOT EXISTS idx_workflow_assignments_assigned
ON workflow_assignments(assigned_to);
-- ── Workflow Signoffs (v0.3.4) ────────────────
CREATE TABLE IF NOT EXISTS workflow_signoffs (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE,
stage TEXT NOT NULL,
user_id TEXT NOT NULL,
decision TEXT NOT NULL CHECK (decision IN ('approve', 'reject')),
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(instance_id, stage, user_id)
);
CREATE INDEX IF NOT EXISTS idx_workflow_signoffs_instance_stage
ON workflow_signoffs(instance_id, stage);

View File

@@ -256,7 +256,8 @@ func TruncateAll(t *testing.T) {
// HA
"rate_limit_counters",
"ws_tickets",
// Workflows (FK order: assignments → instances → versions → stages → workflows)
// Workflows (FK order: signoffs → assignments → instances → versions → stages → workflows)
"workflow_signoffs",
"workflow_assignments",
"workflow_instances",
"workflow_versions",

View File

@@ -67,6 +67,8 @@ var routeTable = map[string]Direction{
"workflow.error": DirToClient, // engine/hook error
"workflow.sla_breach": DirToClient, // SLA exceeded → team + admins (v0.3.3)
"workflow.stale": DirToClient, // instance marked stale (v0.3.3)
"workflow.signoff": DirToClient, // signoff submitted (v0.3.4)
"workflow.rejected": DirToClient, // rejection triggered cancel/reroute (v0.3.4)
// Plugin hooks — never cross the wire
"plugin.hook.": DirLocal,

View File

@@ -1,7 +1,10 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
@@ -29,11 +32,11 @@ type updateTeamRequest struct {
type addMemberRequest struct {
UserID string `json:"user_id" binding:"required"`
Role string `json:"role" binding:"required,oneof=admin member"`
Role string `json:"role" binding:"required,min=1,max=50"`
}
type updateMemberRequest struct {
Role string `json:"role" binding:"required,oneof=admin member"`
Role string `json:"role" binding:"required,min=1,max=50"`
}
// ── Handler ─────────────────────────────────
@@ -258,6 +261,12 @@ func (h *TeamHandler) AddMember(c *gin.Context) {
return
}
// Validate role against team's configured roles
if err := validateTeamRole(ctx, h.stores, teamID, req.Role); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
id, err := h.stores.Teams.AddMemberReturningID(ctx, teamID, req.UserID, req.Role)
if err != nil {
if database.IsUniqueViolation(err) {
@@ -286,6 +295,12 @@ func (h *TeamHandler) UpdateMember(c *gin.Context) {
return
}
// Validate role against team's configured roles
if err := validateTeamRole(c.Request.Context(), h.stores, teamID, req.Role); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := h.stores.Teams.UpdateMemberRoleByID(c.Request.Context(), memberID, teamID, req.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
@@ -403,3 +418,101 @@ func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"actions": actions})
}
// ── Team Roles API (v0.3.4) ─────────────────
// builtinRoles are always present in every team's role list.
var builtinRoles = []string{"admin", "member"}
// ListRoles returns the team's configured roles (builtins + custom).
// GET /api/v1/teams/:teamId/roles
func (h *TeamHandler) ListRoles(c *gin.Context) {
teamID := getTeamID(c)
roles := getTeamRoles(c.Request.Context(), h.stores, teamID)
c.JSON(http.StatusOK, gin.H{"data": roles})
}
// UpdateRoles replaces the team's roles array. Builtins (admin, member) are always included.
// PUT /api/v1/teams/:teamId/roles
func (h *TeamHandler) UpdateRoles(c *gin.Context) {
teamID := getTeamID(c)
var body struct {
Roles []string `json:"roles" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Ensure builtins are present
roleSet := map[string]bool{}
for _, r := range builtinRoles {
roleSet[r] = true
}
for _, r := range body.Roles {
if r != "" && len(r) <= 50 {
roleSet[r] = true
}
}
roles := make([]string, 0, len(roleSet))
for r := range roleSet {
roles = append(roles, r)
}
rolesJSON, _ := json.Marshal(map[string]any{"roles": roles})
if err := h.stores.Teams.MergeSettings(c.Request.Context(), teamID, string(rolesJSON)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update roles"})
return
}
c.JSON(http.StatusOK, gin.H{"data": roles})
AuditLog(h.stores.Audit, c, "team.update_roles", "team", teamID, map[string]interface{}{"roles": roles})
}
// getTeamRoles reads the roles array from team settings, falling back to builtins.
func getTeamRoles(ctx context.Context, stores store.Stores, teamID string) []string {
team, err := stores.Teams.GetByID(ctx, teamID)
if err != nil || team.Settings == nil {
return builtinRoles
}
rolesRaw, ok := team.Settings["roles"]
if !ok {
return builtinRoles
}
// rolesRaw is []interface{} from JSONMap
arr, ok := rolesRaw.([]interface{})
if !ok {
return builtinRoles
}
roles := make([]string, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
roles = append(roles, s)
}
}
if len(roles) == 0 {
return builtinRoles
}
return roles
}
// validateTeamRole checks that a role string is valid for the given team.
func validateTeamRole(ctx context.Context, stores store.Stores, teamID, role string) error {
roles := getTeamRoles(ctx, stores, teamID)
for _, r := range roles {
if r == role {
return nil
}
}
// Also allow builtins unconditionally (in case settings are empty)
for _, r := range builtinRoles {
if r == role {
return nil
}
}
return fmt.Errorf("role %q is not configured for this team", role)
}

View File

@@ -30,12 +30,28 @@ func NewWorkflowAssignmentHandler(engine *workflow.Engine, stores store.Stores)
func (h *WorkflowAssignmentHandler) Claim(c *gin.Context) {
userID := c.GetString("user_id")
assignmentID := c.Param("id")
ctx := c.Request.Context()
if err := h.stores.Workflows.ClaimAssignment(c.Request.Context(), assignmentID, userID); err != nil {
// Claim first, then verify role. Rollback if role check fails.
if err := h.stores.Workflows.ClaimAssignment(ctx, assignmentID, userID); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
// Role check: find the assignment in user's claimed list and verify role
claimed, _ := h.stores.Workflows.ListAssignmentsByUser(ctx, userID, models.AssignmentStatusClaimed)
for _, a := range claimed {
if a.ID == assignmentID {
if rerr := workflow.CheckClaimRole(ctx, h.stores, &a, userID); rerr != nil {
// Rollback: unclaim
h.stores.Workflows.UnclaimAssignment(ctx, assignmentID)
c.JSON(http.StatusForbidden, gin.H{"error": rerr.Error()})
return
}
break
}
}
c.JSON(http.StatusOK, gin.H{"claimed": true})
}

View File

@@ -0,0 +1,72 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/models"
"switchboard-core/store"
"switchboard-core/workflow"
)
// ── Signoff Handlers (v0.3.4) ─────────────────
// WorkflowSignoffHandler manages signoff HTTP endpoints.
type WorkflowSignoffHandler struct {
engine *workflow.Engine
stores store.Stores
}
// NewWorkflowSignoffHandler creates a signoff handler.
func NewWorkflowSignoffHandler(engine *workflow.Engine, stores store.Stores) *WorkflowSignoffHandler {
return &WorkflowSignoffHandler{engine: engine, stores: stores}
}
// Submit records a signoff (approve/reject) for the current stage of an instance.
// POST /api/v1/instances/:iid/signoffs
func (h *WorkflowSignoffHandler) Submit(c *gin.Context) {
userID := c.GetString("user_id")
instanceID := c.Param("iid")
var body struct {
Decision string `json:"decision" binding:"required,oneof=approve reject"`
Comment string `json:"comment"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
so, err := h.engine.SubmitSignoff(c.Request.Context(), instanceID, userID, body.Decision, body.Comment)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, so)
}
// List returns signoffs for the current stage of an instance.
// GET /api/v1/instances/:iid/signoffs
func (h *WorkflowSignoffHandler) List(c *gin.Context) {
instanceID := c.Param("iid")
// Get the instance to determine current stage
inst, err := h.stores.Workflows.GetInstance(c.Request.Context(), instanceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "instance not found"})
return
}
signoffs, err := h.stores.Workflows.ListSignoffs(c.Request.Context(), instanceID, inst.CurrentStage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
if signoffs == nil {
signoffs = []models.WorkflowSignoff{}
}
c.JSON(http.StatusOK, gin.H{"data": signoffs})
}

View File

@@ -762,3 +762,135 @@ func TestWorkflowAssignment_ListByTeam(t *testing.T) {
t.Errorf("teamB count = %d, want 1", len(listB))
}
}
// ── Signoff store tests (v0.3.4) ──────────────
func TestWorkflowSignoff_CreateAndList(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Signer", "signer@test.com")
teamID := database.SeedTestTeam(t, "Signoff Team", 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)
// Create two signoffs
so1 := &models.WorkflowSignoff{
InstanceID: inst.ID, Stage: stage1,
UserID: userID, Decision: models.SignoffApprove, Comment: "LGTM",
}
if err := s.Workflows.CreateSignoff(ctx, so1); err != nil {
t.Fatalf("create signoff: %v", err)
}
if so1.ID == "" {
t.Fatal("signoff ID not populated")
}
if so1.CreatedAt.IsZero() {
t.Fatal("signoff created_at not populated")
}
user2 := database.SeedTestUser(t, "Signer2", "signer2@test.com")
so2 := &models.WorkflowSignoff{
InstanceID: inst.ID, Stage: stage1,
UserID: user2, Decision: models.SignoffReject, Comment: "Needs work",
}
if err := s.Workflows.CreateSignoff(ctx, so2); err != nil {
t.Fatalf("create signoff 2: %v", err)
}
// List should return both, ordered by created_at
list, err := s.Workflows.ListSignoffs(ctx, inst.ID, stage1)
if err != nil {
t.Fatalf("list signoffs: %v", err)
}
if len(list) != 2 {
t.Fatalf("signoff count = %d, want 2", len(list))
}
if list[0].Decision != models.SignoffApprove {
t.Errorf("first signoff decision = %s, want approve", list[0].Decision)
}
if list[1].Decision != models.SignoffReject {
t.Errorf("second signoff decision = %s, want reject", list[1].Decision)
}
// UNIQUE constraint: same user + instance + stage should fail
dup := &models.WorkflowSignoff{
InstanceID: inst.ID, Stage: stage1,
UserID: userID, Decision: models.SignoffApprove,
}
if err := s.Workflows.CreateSignoff(ctx, dup); err == nil {
t.Fatal("expected UNIQUE violation for duplicate signoff, got nil")
}
}
func TestWorkflowSignoff_ListEmpty(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
// ListSignoffs on non-existent instance should return empty, not error
list, err := s.Workflows.ListSignoffs(ctx, "nonexistent", "stage")
if err != nil {
t.Fatalf("list signoffs error: %v", err)
}
if list != nil && len(list) != 0 {
t.Fatalf("expected empty list, got %d", len(list))
}
}
func TestWorkflowSignoff_Count(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
s := testStores(t)
ctx := context.Background()
userID := database.SeedTestUser(t, "Counter", "counter@test.com")
teamID := database.SeedTestTeam(t, "Count Team", 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)
// No signoffs yet
count, err := s.Workflows.CountSignoffs(ctx, inst.ID, stage1, models.SignoffApprove)
if err != nil {
t.Fatalf("count signoffs error: %v", err)
}
if count != 0 {
t.Fatalf("expected 0, got %d", count)
}
// Add 2 approvals and 1 rejection
user2 := database.SeedTestUser(t, "Counter2", "counter2@test.com")
user3 := database.SeedTestUser(t, "Counter3", "counter3@test.com")
s.Workflows.CreateSignoff(ctx, &models.WorkflowSignoff{
InstanceID: inst.ID, Stage: stage1, UserID: userID, Decision: models.SignoffApprove,
})
s.Workflows.CreateSignoff(ctx, &models.WorkflowSignoff{
InstanceID: inst.ID, Stage: stage1, UserID: user2, Decision: models.SignoffApprove,
})
s.Workflows.CreateSignoff(ctx, &models.WorkflowSignoff{
InstanceID: inst.ID, Stage: stage1, UserID: user3, Decision: models.SignoffReject,
})
approveCount, _ := s.Workflows.CountSignoffs(ctx, inst.ID, stage1, models.SignoffApprove)
if approveCount != 2 {
t.Errorf("approve count = %d, want 2", approveCount)
}
rejectCount, _ := s.Workflows.CountSignoffs(ctx, inst.ID, stage1, models.SignoffReject)
if rejectCount != 1 {
t.Errorf("reject count = %d, want 1", rejectCount)
}
}

View File

@@ -433,6 +433,11 @@ func main() {
protected.POST("/assignments/:id/cancel", middleware.RequirePermission(auth.PermWorkflowCreate, stores), wfAssignH.Cancel)
protected.GET("/assignments/mine", wfAssignH.ListMine)
// Workflow signoffs (v0.3.4)
wfSignoffH := handlers.NewWorkflowSignoffHandler(wfEngine, stores)
protected.POST("/instances/:iid/signoffs", wfSignoffH.Submit)
protected.GET("/instances/:iid/signoffs", wfSignoffH.List)
// Surface discovery (v0.25.0, v0.28.7: unified packages)
pkgH := handlers.NewPackageHandler(stores)
protected.GET("/surfaces", pkgH.ListEnabledSurfaces)
@@ -515,6 +520,8 @@ func main() {
teamScoped.POST("/members", teams.AddMember)
teamScoped.PUT("/members/:memberId", teams.UpdateMember)
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
teamScoped.GET("/roles", teams.ListRoles)
teamScoped.PUT("/roles", teams.UpdateRoles)
// Team groups (team admins manage team-scoped groups)
teamScoped.GET("/groups", groupH.ListTeamGroups)
@@ -566,6 +573,11 @@ func main() {
teamWfAssignH := handlers.NewWorkflowAssignmentHandler(wfEngine, stores)
teamScoped.GET("/assignments", teamWfAssignH.ListByTeam)
// Team workflow signoffs (v0.3.4)
teamWfSignoffH := handlers.NewWorkflowSignoffHandler(wfEngine, stores)
teamScoped.POST("/instances/:iid/signoffs", teamWfSignoffH.Submit)
teamScoped.GET("/instances/:iid/signoffs", teamWfSignoffH.List)
}
// Public global settings (non-admin users can read safe subset)

View File

@@ -504,6 +504,25 @@ type WorkflowAssignment struct {
CreatedAt time.Time `json:"created_at"`
}
// ── Workflow Signoff (v0.3.4) ───────────────
// WorkflowSignoff records a user's approval or rejection at a stage boundary.
type WorkflowSignoff struct {
ID string `json:"id"`
InstanceID string `json:"instance_id"`
Stage string `json:"stage"`
UserID string `json:"user_id"`
Decision string `json:"decision"` // approve | reject
Comment string `json:"comment"`
CreatedAt time.Time `json:"created_at"`
}
// Signoff decision constants.
const (
SignoffApprove = "approve"
SignoffReject = "reject"
)
// ── Workflow Version (immutable snapshot) ───
// WorkflowVersion is an immutable snapshot of a workflow definition

View File

@@ -660,6 +660,48 @@ func (s *WorkflowStore) ListAssignmentsByUser(ctx context.Context, userID string
return s.queryAssignments(ctx, q, args...)
}
// ── Signoffs (v0.3.4) ─────────────────────────
func (s *WorkflowStore) CreateSignoff(ctx context.Context, so *models.WorkflowSignoff) error {
return DB.QueryRowContext(ctx, `
INSERT INTO workflow_signoffs (instance_id, stage, user_id, decision, comment)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at`,
so.InstanceID, so.Stage, so.UserID, so.Decision, so.Comment,
).Scan(&so.ID, &so.CreatedAt)
}
func (s *WorkflowStore) ListSignoffs(ctx context.Context, instanceID, stage string) ([]models.WorkflowSignoff, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, instance_id, stage, user_id, decision, comment, created_at
FROM workflow_signoffs
WHERE instance_id = $1 AND stage = $2
ORDER BY created_at`, instanceID, stage)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.WorkflowSignoff
for rows.Next() {
var so models.WorkflowSignoff
if err := rows.Scan(&so.ID, &so.InstanceID, &so.Stage, &so.UserID,
&so.Decision, &so.Comment, &so.CreatedAt); err != nil {
return nil, err
}
result = append(result, so)
}
return result, rows.Err()
}
func (s *WorkflowStore) CountSignoffs(ctx context.Context, instanceID, stage, decision string) (int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM workflow_signoffs
WHERE instance_id = $1 AND stage = $2 AND decision = $3`,
instanceID, stage, decision).Scan(&count)
return count, err
}
func (s *WorkflowStore) queryAssignments(ctx context.Context, q string, args ...interface{}) ([]models.WorkflowAssignment, error) {
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {

View File

@@ -686,6 +686,50 @@ func (s *WorkflowStore) ListAssignmentsByUser(ctx context.Context, userID string
return s.queryAssignments(ctx, q, args...)
}
// ── Signoffs (v0.3.4) ─────────────────────────
func (s *WorkflowStore) CreateSignoff(ctx context.Context, so *models.WorkflowSignoff) error {
so.ID = store.NewID()
so.CreatedAt = time.Now().UTC()
_, err := DB.ExecContext(ctx, `
INSERT INTO workflow_signoffs (id, instance_id, stage, user_id, decision, comment, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
so.ID, so.InstanceID, so.Stage, so.UserID, so.Decision, so.Comment,
so.CreatedAt.Format(timeFmt))
return err
}
func (s *WorkflowStore) ListSignoffs(ctx context.Context, instanceID, stage string) ([]models.WorkflowSignoff, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, instance_id, stage, user_id, decision, comment, created_at
FROM workflow_signoffs
WHERE instance_id = ? AND stage = ?
ORDER BY created_at`, instanceID, stage)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.WorkflowSignoff
for rows.Next() {
var so models.WorkflowSignoff
if err := rows.Scan(&so.ID, &so.InstanceID, &so.Stage, &so.UserID,
&so.Decision, &so.Comment, st(&so.CreatedAt)); err != nil {
return nil, err
}
result = append(result, so)
}
return result, rows.Err()
}
func (s *WorkflowStore) CountSignoffs(ctx context.Context, instanceID, stage, decision string) (int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM workflow_signoffs
WHERE instance_id = ? AND stage = ? AND decision = ?`,
instanceID, stage, decision).Scan(&count)
return count, err
}
func (s *WorkflowStore) queryAssignments(ctx context.Context, q string, args ...interface{}) ([]models.WorkflowAssignment, error) {
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {

View File

@@ -52,4 +52,9 @@ type WorkflowStore interface {
ListAssignmentsByTeam(ctx context.Context, teamID string, status string) ([]models.WorkflowAssignment, error)
ListAssignmentsByInstance(ctx context.Context, instanceID string) ([]models.WorkflowAssignment, error)
ListAssignmentsByUser(ctx context.Context, userID string, status string) ([]models.WorkflowAssignment, error)
// Signoffs (v0.3.4)
CreateSignoff(ctx context.Context, s *models.WorkflowSignoff) error
ListSignoffs(ctx context.Context, instanceID, stage string) ([]models.WorkflowSignoff, error)
CountSignoffs(ctx context.Context, instanceID, stage, decision string) (int, error)
}

View File

@@ -139,6 +139,44 @@ func (e *Engine) advanceInternal(ctx context.Context, instanceID string, stageDa
}
currentStage := stages[currentOrdinal]
// ── Validation gate (v0.3.4) ──────────────
sc := ParseStageConfig(currentStage.StageConfig)
if sc.Validation != nil && sc.Validation.RequiredApprovals > 0 {
// Check for rejections first
rejectCount, _ := e.stores.Workflows.CountSignoffs(ctx, instanceID, currentStage.Name, models.SignoffReject)
if rejectCount > 0 {
action := sc.Validation.RejectAction
if action == "" || action == "cancel" {
_ = e.Cancel(ctx, instanceID, userID)
e.emit(ctx, "workflow.rejected", instanceID, map[string]any{
"instance_id": instanceID,
"stage": currentStage.Name,
})
return e.stores.Workflows.GetInstance(ctx, instanceID)
}
// Reroute to named stage
target, rerr := ResolveStageByName(stages, action)
if rerr != nil {
return nil, fmt.Errorf("reject_action stage %q not found: %w", action, rerr)
}
merged := mergeJSON(inst.StageData, stageData)
if err := e.stores.Workflows.AdvanceStage(ctx, instanceID, stages[target].Name, merged); err != nil {
return nil, fmt.Errorf("reject reroute: %w", err)
}
e.emit(ctx, "workflow.rejected", instanceID, map[string]any{
"instance_id": instanceID,
"stage": currentStage.Name,
"rerouted_to": stages[target].Name,
})
return e.stores.Workflows.GetInstance(ctx, instanceID)
}
approveCount, _ := e.stores.Workflows.CountSignoffs(ctx, instanceID, currentStage.Name, models.SignoffApprove)
if approveCount < sc.Validation.RequiredApprovals {
return nil, fmt.Errorf("insufficient approvals (%d of %d required)", approveCount, sc.Validation.RequiredApprovals)
}
}
// Merge stage data
merged := mergeJSON(inst.StageData, stageData)
@@ -301,6 +339,112 @@ func (e *Engine) AdvancePublic(ctx context.Context, entryToken string, stageData
return e.advanceInternal(ctx, inst.ID, stageData, inst.StartedBy, 0)
}
// ── Signoffs (v0.3.4) ─────────────────────────
// SubmitSignoff records a user's approval or rejection at the current stage boundary.
func (e *Engine) SubmitSignoff(ctx context.Context, instanceID, userID, decision, comment string) (*models.WorkflowSignoff, error) {
inst, err := e.stores.Workflows.GetInstance(ctx, instanceID)
if err != nil {
return nil, fmt.Errorf("instance not found: %w", err)
}
if inst.Status != models.InstanceStatusActive {
return nil, fmt.Errorf("instance is %s, not active", inst.Status)
}
// Load version snapshot to get current stage config
ver, err := e.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion)
if err != nil {
return nil, fmt.Errorf("version not found: %w", err)
}
var stages []models.WorkflowStage
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
return nil, fmt.Errorf("corrupt snapshot: %w", err)
}
// Find current stage
var currentStage *models.WorkflowStage
for i := range stages {
if stages[i].Name == inst.CurrentStage {
currentStage = &stages[i]
break
}
}
if currentStage == nil {
return nil, fmt.Errorf("current stage %q not found", inst.CurrentStage)
}
sc := ParseStageConfig(currentStage.StageConfig)
if sc.Validation == nil || sc.Validation.RequiredApprovals == 0 {
return nil, fmt.Errorf("stage %q does not require signoffs", currentStage.Name)
}
// Role check: if validation has a required_role, verify the user has it
if sc.Validation.RequiredRole != "" && currentStage.AssignmentTeamID != nil {
member, merr := e.stores.Teams.GetMember(ctx, *currentStage.AssignmentTeamID, userID)
if merr != nil {
return nil, fmt.Errorf("user is not a member of the assignment team")
}
if member.Role != sc.Validation.RequiredRole {
return nil, fmt.Errorf("role %q required to sign off (you have %q)", sc.Validation.RequiredRole, member.Role)
}
}
so := &models.WorkflowSignoff{
InstanceID: instanceID,
Stage: inst.CurrentStage,
UserID: userID,
Decision: decision,
Comment: comment,
}
if err := e.stores.Workflows.CreateSignoff(ctx, so); err != nil {
return nil, fmt.Errorf("create signoff: %w", err)
}
e.emit(ctx, "workflow.signoff", instanceID, map[string]any{
"instance_id": instanceID,
"stage": inst.CurrentStage,
"user_id": userID,
"decision": decision,
})
return so, nil
}
// CheckClaimRole verifies that a user has the required_role (from stage_config)
// to claim an assignment. Returns nil if allowed, error if forbidden.
func CheckClaimRole(ctx context.Context, stores store.Stores, assignment *models.WorkflowAssignment, userID string) error {
inst, err := stores.Workflows.GetInstance(ctx, assignment.InstanceID)
if err != nil {
return nil // can't verify — allow claim
}
ver, err := stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion)
if err != nil {
return nil
}
var stages []models.WorkflowStage
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
return nil
}
for _, s := range stages {
if s.Name == assignment.Stage {
sc := ParseStageConfig(s.StageConfig)
if sc.RequiredRole == "" {
return nil // no role restriction
}
member, merr := stores.Teams.GetMember(ctx, assignment.TeamID, userID)
if merr != nil {
return fmt.Errorf("not a member of team")
}
if member.Role != sc.RequiredRole {
return fmt.Errorf("role %q required for this stage (you have %q)", sc.RequiredRole, member.Role)
}
return nil
}
}
return nil // stage not found in snapshot — allow
}
// emit publishes an event on the bus.
func (e *Engine) emit(_ context.Context, label, room string, payload map[string]any) {
if e.bus == nil {

View File

@@ -24,8 +24,28 @@ type Condition struct {
// StageConfig holds non-routing config from stage_config JSON.
type StageConfig struct {
AutoAssign string `json:"auto_assign,omitempty"`
OnAdvance *OnAdvanceReference `json:"on_advance,omitempty"`
AutoAssign string `json:"auto_assign,omitempty"`
RequiredRole string `json:"required_role,omitempty"`
Validation *ValidationConfig `json:"validation,omitempty"`
OnAdvance *OnAdvanceReference `json:"on_advance,omitempty"`
}
// ValidationConfig defines multi-party sign-off requirements for a stage.
type ValidationConfig struct {
RequiredApprovals int `json:"required_approvals"` // e.g. 2
RequiredRole string `json:"required_role,omitempty"` // role needed to sign off
RejectAction string `json:"reject_action,omitempty"` // "cancel" | stage name
}
// ParseStageConfig unmarshals a stage_config JSON blob into StageConfig.
// Returns zero-value StageConfig on empty/invalid input.
func ParseStageConfig(raw json.RawMessage) StageConfig {
var sc StageConfig
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
return sc
}
_ = json.Unmarshal(raw, &sc)
return sc
}
// OnAdvanceReference points to a Starlark hook for on_advance processing.