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