feat(api): approvals + ECO workflow API with YAML-configurable workflows
- Add internal/workflow/ package for YAML workflow definitions (Load, LoadAll, Validate)
- Add internal/db/item_approvals.go repository (Create, AddSignature, GetWithSignatures, ListByItemWithSignatures, UpdateState, UpdateSignature)
- Add internal/api/approval_handlers.go with 4 endpoints:
- GET /{partNumber}/approvals (list approvals with signatures)
- POST /{partNumber}/approvals (create ECO with workflow + signers)
- POST /{partNumber}/approvals/{id}/sign (approve or reject)
- GET /workflows (list available workflow definitions)
- Rule-driven state transitions: any_reject and all_required_approve
- Pack approvals into silo/approvals.json on .kc checkout
- Add WorkflowsConfig to config, load workflows at startup
- Migration 019: add workflow_name column to item_approvals
- Example workflows: engineering-change.yaml, quick-review.yaml
- 7 workflow tests, all passing
Closes #145
This commit is contained in:
391
internal/api/approval_handlers.go
Normal file
391
internal/api/approval_handlers.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
"github.com/kindredsystems/silo/internal/db"
|
||||
"github.com/kindredsystems/silo/internal/workflow"
|
||||
)
|
||||
|
||||
// ApprovalResponse is the JSON representation for approval endpoints.
|
||||
type ApprovalResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkflowName string `json:"workflow"`
|
||||
ECONumber *string `json:"eco_number"`
|
||||
State string `json:"state"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
Signatures []SignatureResponse `json:"signatures"`
|
||||
}
|
||||
|
||||
// SignatureResponse is the JSON representation for a signature.
|
||||
type SignatureResponse struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
SignedAt *string `json:"signed_at"`
|
||||
Comment *string `json:"comment"`
|
||||
}
|
||||
|
||||
// CreateApprovalRequest is the JSON body for POST /approvals.
|
||||
type CreateApprovalRequest struct {
|
||||
Workflow string `json:"workflow"`
|
||||
ECONumber string `json:"eco_number"`
|
||||
Signers []SignerRequest `json:"signers"`
|
||||
}
|
||||
|
||||
// SignerRequest defines a signer in the create request.
|
||||
type SignerRequest struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// SignApprovalRequest is the JSON body for POST /approvals/{id}/sign.
|
||||
type SignApprovalRequest struct {
|
||||
Status string `json:"status"`
|
||||
Comment *string `json:"comment"`
|
||||
}
|
||||
|
||||
func approvalToResponse(a *db.ItemApproval) ApprovalResponse {
|
||||
sigs := make([]SignatureResponse, len(a.Signatures))
|
||||
for i, s := range a.Signatures {
|
||||
var signedAt *string
|
||||
if s.SignedAt != nil {
|
||||
t := s.SignedAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||
signedAt = &t
|
||||
}
|
||||
sigs[i] = SignatureResponse{
|
||||
Username: s.Username,
|
||||
Role: s.Role,
|
||||
Status: s.Status,
|
||||
SignedAt: signedAt,
|
||||
Comment: s.Comment,
|
||||
}
|
||||
}
|
||||
return ApprovalResponse{
|
||||
ID: a.ID,
|
||||
WorkflowName: a.WorkflowName,
|
||||
ECONumber: a.ECONumber,
|
||||
State: a.State,
|
||||
UpdatedAt: a.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedBy: a.UpdatedBy,
|
||||
Signatures: sigs,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleGetApprovals returns all approvals with signatures for an item.
|
||||
// GET /api/items/{partNumber}/approvals
|
||||
func (s *Server) HandleGetApprovals(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
partNumber := chi.URLParam(r, "partNumber")
|
||||
|
||||
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get item")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
|
||||
approvals, err := s.approvals.ListByItemWithSignatures(ctx, item.ID)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to list approvals")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list approvals")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]ApprovalResponse, len(approvals))
|
||||
for i, a := range approvals {
|
||||
resp[i] = approvalToResponse(a)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HandleCreateApproval creates an ECO with a workflow and signers.
|
||||
// POST /api/items/{partNumber}/approvals
|
||||
func (s *Server) HandleCreateApproval(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
partNumber := chi.URLParam(r, "partNumber")
|
||||
|
||||
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get item")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateApprovalRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Signers) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body", "At least one signer is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate workflow exists
|
||||
wf, ok := s.workflows[req.Workflow]
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid_workflow", "Workflow '"+req.Workflow+"' not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate each signer's role matches a gate in the workflow
|
||||
for _, signer := range req.Signers {
|
||||
if !wf.HasRole(signer.Role) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_role",
|
||||
"Role '"+signer.Role+"' is not defined in workflow '"+req.Workflow+"'")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate all required gates have at least one signer
|
||||
signerRoles := make(map[string]bool)
|
||||
for _, signer := range req.Signers {
|
||||
signerRoles[signer.Role] = true
|
||||
}
|
||||
for _, gate := range wf.RequiredGates() {
|
||||
if !signerRoles[gate.Role] {
|
||||
writeError(w, http.StatusBadRequest, "missing_required_signer",
|
||||
"Required role '"+gate.Role+"' ("+gate.Label+") has no assigned signer")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
username := ""
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
var ecoNumber *string
|
||||
if req.ECONumber != "" {
|
||||
ecoNumber = &req.ECONumber
|
||||
}
|
||||
|
||||
approval := &db.ItemApproval{
|
||||
ItemID: item.ID,
|
||||
WorkflowName: req.Workflow,
|
||||
ECONumber: ecoNumber,
|
||||
State: "pending",
|
||||
UpdatedBy: &username,
|
||||
}
|
||||
|
||||
if err := s.approvals.Create(ctx, approval); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to create approval")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create approval")
|
||||
return
|
||||
}
|
||||
|
||||
// Add signature rows for each signer
|
||||
for _, signer := range req.Signers {
|
||||
sig := &db.ApprovalSignature{
|
||||
ApprovalID: approval.ID,
|
||||
Username: signer.Username,
|
||||
Role: signer.Role,
|
||||
Status: "pending",
|
||||
}
|
||||
if err := s.approvals.AddSignature(ctx, sig); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to add signature")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to add signer")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch with signatures for response
|
||||
approval, err = s.approvals.GetWithSignatures(ctx, approval.ID)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get approval")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get approval")
|
||||
return
|
||||
}
|
||||
|
||||
resp := approvalToResponse(approval)
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
s.broker.Publish("approval.created", mustMarshal(map[string]any{
|
||||
"part_number": partNumber,
|
||||
"approval_id": approval.ID,
|
||||
"workflow": approval.WorkflowName,
|
||||
"eco_number": approval.ECONumber,
|
||||
}))
|
||||
}
|
||||
|
||||
// HandleSignApproval records an approve or reject signature.
|
||||
// POST /api/items/{partNumber}/approvals/{id}/sign
|
||||
func (s *Server) HandleSignApproval(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
partNumber := chi.URLParam(r, "partNumber")
|
||||
approvalID := chi.URLParam(r, "id")
|
||||
|
||||
item, err := s.items.GetByPartNumber(ctx, partNumber)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get item")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||
return
|
||||
}
|
||||
if item == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
|
||||
approval, err := s.approvals.GetWithSignatures(ctx, approvalID)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get approval")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get approval")
|
||||
return
|
||||
}
|
||||
if approval == nil || approval.ItemID != item.ID {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Approval not found")
|
||||
return
|
||||
}
|
||||
|
||||
if approval.State != "pending" {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid_state",
|
||||
"Approval is in state '"+approval.State+"', signatures can only be added when 'pending'")
|
||||
return
|
||||
}
|
||||
|
||||
var req SignApprovalRequest
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status != "approved" && req.Status != "rejected" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_status", "Status must be 'approved' or 'rejected'")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the caller's username
|
||||
username := ""
|
||||
if user := auth.UserFromContext(ctx); user != nil {
|
||||
username = user.Username
|
||||
}
|
||||
|
||||
// Check that the caller has a pending signature on this approval
|
||||
sig, err := s.approvals.GetSignatureForUser(ctx, approvalID, username)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get signature")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check signature")
|
||||
return
|
||||
}
|
||||
if sig == nil {
|
||||
writeError(w, http.StatusForbidden, "not_a_signer", "You are not a signer on this approval")
|
||||
return
|
||||
}
|
||||
if sig.Status != "pending" {
|
||||
writeError(w, http.StatusConflict, "already_signed", "You have already signed this approval")
|
||||
return
|
||||
}
|
||||
|
||||
// Update the signature
|
||||
if err := s.approvals.UpdateSignature(ctx, sig.ID, req.Status, req.Comment); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to update signature")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update signature")
|
||||
return
|
||||
}
|
||||
|
||||
s.broker.Publish("approval.signed", mustMarshal(map[string]any{
|
||||
"part_number": partNumber,
|
||||
"approval_id": approvalID,
|
||||
"username": username,
|
||||
"status": req.Status,
|
||||
}))
|
||||
|
||||
// Evaluate auto-advance based on workflow rules
|
||||
wf := s.workflows[approval.WorkflowName]
|
||||
if wf != nil {
|
||||
// Re-fetch signatures after update
|
||||
approval, err = s.approvals.GetWithSignatures(ctx, approvalID)
|
||||
if err == nil && approval != nil {
|
||||
newState := evaluateApprovalState(wf, approval)
|
||||
if newState != "" && newState != approval.State {
|
||||
if err := s.approvals.UpdateState(ctx, approvalID, newState, username); err != nil {
|
||||
s.logger.Warn().Err(err).Msg("failed to auto-advance approval state")
|
||||
} else {
|
||||
approval.State = newState
|
||||
s.broker.Publish("approval.completed", mustMarshal(map[string]any{
|
||||
"part_number": partNumber,
|
||||
"approval_id": approvalID,
|
||||
"state": newState,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated approval
|
||||
if approval == nil {
|
||||
approval, _ = s.approvals.GetWithSignatures(ctx, approvalID)
|
||||
}
|
||||
if approval != nil {
|
||||
writeJSON(w, http.StatusOK, approvalToResponse(approval))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListWorkflows returns all loaded workflow definitions.
|
||||
// GET /api/workflows
|
||||
func (s *Server) HandleListWorkflows(w http.ResponseWriter, r *http.Request) {
|
||||
resp := make([]map[string]any, 0, len(s.workflows))
|
||||
for _, wf := range s.workflows {
|
||||
resp = append(resp, map[string]any{
|
||||
"name": wf.Name,
|
||||
"version": wf.Version,
|
||||
"description": wf.Description,
|
||||
"gates": wf.Gates,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// evaluateApprovalState checks workflow rules against current signatures
|
||||
// and returns the new state, or "" if no transition is needed.
|
||||
func evaluateApprovalState(wf *workflow.Workflow, approval *db.ItemApproval) string {
|
||||
// Check for any rejection
|
||||
if wf.Rules.AnyReject != "" {
|
||||
for _, sig := range approval.Signatures {
|
||||
if sig.Status == "rejected" {
|
||||
return wf.Rules.AnyReject
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all required roles have approved
|
||||
if wf.Rules.AllRequiredApprove != "" {
|
||||
requiredRoles := make(map[string]bool)
|
||||
for _, gate := range wf.RequiredGates() {
|
||||
requiredRoles[gate.Role] = true
|
||||
}
|
||||
|
||||
// For each required role, check that all signers with that role have approved
|
||||
for _, sig := range approval.Signatures {
|
||||
if requiredRoles[sig.Role] && sig.Status != "approved" {
|
||||
return "" // at least one required signer hasn't approved yet
|
||||
}
|
||||
}
|
||||
|
||||
// All required signers approved
|
||||
return wf.Rules.AllRequiredApprove
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// readJSON decodes a JSON request body.
|
||||
func readJSON(r *http.Request, v any) error {
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
Reference in New Issue
Block a user