Files
silo/internal/db/item_approvals.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

213 lines
6.5 KiB
Go

package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ItemApproval represents a row in the item_approvals table.
type ItemApproval struct {
ID string
ItemID string
WorkflowName string
ECONumber *string
State string // draft | pending | approved | rejected
UpdatedAt time.Time
UpdatedBy *string
Signatures []ApprovalSignature // populated by WithSignatures methods
}
// ApprovalSignature represents a row in the approval_signatures table.
type ApprovalSignature struct {
ID string
ApprovalID string
Username string
Role string
Status string // pending | approved | rejected
SignedAt *time.Time
Comment *string
}
// ItemApprovalRepository provides item_approvals database operations.
type ItemApprovalRepository struct {
db *DB
}
// NewItemApprovalRepository creates a new item approval repository.
func NewItemApprovalRepository(db *DB) *ItemApprovalRepository {
return &ItemApprovalRepository{db: db}
}
// Create inserts a new approval row. The ID is populated on return.
func (r *ItemApprovalRepository) Create(ctx context.Context, a *ItemApproval) error {
return r.db.pool.QueryRow(ctx, `
INSERT INTO item_approvals (item_id, workflow_name, eco_number, state, updated_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, updated_at
`, a.ItemID, a.WorkflowName, a.ECONumber, a.State, a.UpdatedBy).Scan(&a.ID, &a.UpdatedAt)
}
// AddSignature inserts a new signature row. The ID is populated on return.
func (r *ItemApprovalRepository) AddSignature(ctx context.Context, s *ApprovalSignature) error {
return r.db.pool.QueryRow(ctx, `
INSERT INTO approval_signatures (approval_id, username, role, status)
VALUES ($1, $2, $3, $4)
RETURNING id
`, s.ApprovalID, s.Username, s.Role, s.Status).Scan(&s.ID)
}
// GetWithSignatures returns a single approval with its signatures.
func (r *ItemApprovalRepository) GetWithSignatures(ctx context.Context, approvalID string) (*ItemApproval, error) {
a := &ItemApproval{}
err := r.db.pool.QueryRow(ctx, `
SELECT id, item_id, workflow_name, eco_number, state, updated_at, updated_by
FROM item_approvals
WHERE id = $1
`, approvalID).Scan(&a.ID, &a.ItemID, &a.WorkflowName, &a.ECONumber, &a.State, &a.UpdatedAt, &a.UpdatedBy)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("getting approval: %w", err)
}
sigs, err := r.signaturesForApproval(ctx, approvalID)
if err != nil {
return nil, err
}
a.Signatures = sigs
return a, nil
}
// ListByItemWithSignatures returns all approvals for an item, each with signatures.
func (r *ItemApprovalRepository) ListByItemWithSignatures(ctx context.Context, itemID string) ([]*ItemApproval, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, item_id, workflow_name, eco_number, state, updated_at, updated_by
FROM item_approvals
WHERE item_id = $1
ORDER BY updated_at DESC
`, itemID)
if err != nil {
return nil, fmt.Errorf("listing approvals: %w", err)
}
defer rows.Close()
var approvals []*ItemApproval
var approvalIDs []string
for rows.Next() {
a := &ItemApproval{}
if err := rows.Scan(&a.ID, &a.ItemID, &a.WorkflowName, &a.ECONumber, &a.State, &a.UpdatedAt, &a.UpdatedBy); err != nil {
return nil, fmt.Errorf("scanning approval: %w", err)
}
approvals = append(approvals, a)
approvalIDs = append(approvalIDs, a.ID)
}
if len(approvalIDs) == 0 {
return approvals, nil
}
// Batch-fetch all signatures
sigRows, err := r.db.pool.Query(ctx, `
SELECT id, approval_id, username, role, status, signed_at, comment
FROM approval_signatures
WHERE approval_id = ANY($1)
ORDER BY username
`, approvalIDs)
if err != nil {
return nil, fmt.Errorf("listing signatures: %w", err)
}
defer sigRows.Close()
sigMap := make(map[string][]ApprovalSignature)
for sigRows.Next() {
var s ApprovalSignature
if err := sigRows.Scan(&s.ID, &s.ApprovalID, &s.Username, &s.Role, &s.Status, &s.SignedAt, &s.Comment); err != nil {
return nil, fmt.Errorf("scanning signature: %w", err)
}
sigMap[s.ApprovalID] = append(sigMap[s.ApprovalID], s)
}
for _, a := range approvals {
a.Signatures = sigMap[a.ID]
if a.Signatures == nil {
a.Signatures = []ApprovalSignature{}
}
}
return approvals, nil
}
// UpdateState updates the approval state and updated_by.
func (r *ItemApprovalRepository) UpdateState(ctx context.Context, approvalID, state, updatedBy string) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE item_approvals
SET state = $2, updated_by = $3, updated_at = now()
WHERE id = $1
`, approvalID, state, updatedBy)
if err != nil {
return fmt.Errorf("updating approval state: %w", err)
}
return nil
}
// GetSignatureForUser returns the signature for a specific user on an approval.
func (r *ItemApprovalRepository) GetSignatureForUser(ctx context.Context, approvalID, username string) (*ApprovalSignature, error) {
s := &ApprovalSignature{}
err := r.db.pool.QueryRow(ctx, `
SELECT id, approval_id, username, role, status, signed_at, comment
FROM approval_signatures
WHERE approval_id = $1 AND username = $2
`, approvalID, username).Scan(&s.ID, &s.ApprovalID, &s.Username, &s.Role, &s.Status, &s.SignedAt, &s.Comment)
if err != nil {
if err == pgx.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("getting signature: %w", err)
}
return s, nil
}
// UpdateSignature updates a signature's status, comment, and signed_at timestamp.
func (r *ItemApprovalRepository) UpdateSignature(ctx context.Context, sigID, status string, comment *string) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE approval_signatures
SET status = $2, comment = $3, signed_at = now()
WHERE id = $1
`, sigID, status, comment)
if err != nil {
return fmt.Errorf("updating signature: %w", err)
}
return nil
}
// signaturesForApproval returns all signatures for a single approval.
func (r *ItemApprovalRepository) signaturesForApproval(ctx context.Context, approvalID string) ([]ApprovalSignature, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, approval_id, username, role, status, signed_at, comment
FROM approval_signatures
WHERE approval_id = $1
ORDER BY username
`, approvalID)
if err != nil {
return nil, fmt.Errorf("listing signatures: %w", err)
}
defer rows.Close()
var sigs []ApprovalSignature
for rows.Next() {
var s ApprovalSignature
if err := rows.Scan(&s.ID, &s.ApprovalID, &s.Username, &s.Role, &s.Status, &s.SignedAt, &s.Comment); err != nil {
return nil, fmt.Errorf("scanning signature: %w", err)
}
sigs = append(sigs, s)
}
if sigs == nil {
sigs = []ApprovalSignature{}
}
return sigs, nil
}