Feat v0.3.4 team roles signoff (#18)
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
72
server/handlers/workflow_signoff_handlers.go
Normal file
72
server/handlers/workflow_signoff_handlers.go
Normal 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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user