- 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
213 lines
6.5 KiB
Go
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
|
|
}
|