- 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
392 lines
12 KiB
Go
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)
|
|
}
|