Files
silo/internal/api/approval_handlers.go
Forbes 12ecffdabe 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
2026-02-18 19:38:20 -06:00

392 lines
12 KiB
Go

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)
}