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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user