- 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
137 lines
3.6 KiB
Go
137 lines
3.6 KiB
Go
package kc
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
)
|
|
|
|
// HasSiloDir opens a ZIP archive and returns true if any entry starts with "silo/".
|
|
// This is a lightweight check used to short-circuit before gathering DB data.
|
|
func HasSiloDir(data []byte) (bool, error) {
|
|
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
return false, fmt.Errorf("kc: open zip: %w", err)
|
|
}
|
|
for _, f := range r.File {
|
|
if f.Name == "silo/" || strings.HasPrefix(f.Name, "silo/") {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// Pack takes original ZIP file bytes and a PackInput, and returns new ZIP bytes
|
|
// with all silo/ entries replaced by the data from input. Non-silo entries
|
|
// (FreeCAD Document.xml, thumbnails, etc.) are copied verbatim with their
|
|
// original compression method and timestamps preserved.
|
|
//
|
|
// If the original ZIP contains no silo/ directory, the original bytes are
|
|
// returned unchanged (plain .fcstd pass-through).
|
|
func Pack(original []byte, input *PackInput) ([]byte, error) {
|
|
r, err := zip.NewReader(bytes.NewReader(original), int64(len(original)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kc: open zip: %w", err)
|
|
}
|
|
|
|
// Partition entries into silo/ vs non-silo.
|
|
hasSilo := false
|
|
for _, f := range r.File {
|
|
if f.Name == "silo/" || strings.HasPrefix(f.Name, "silo/") {
|
|
hasSilo = true
|
|
break
|
|
}
|
|
}
|
|
if !hasSilo {
|
|
return original, nil // plain .fcstd, no repacking needed
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
|
|
// Copy all non-silo entries verbatim.
|
|
for _, f := range r.File {
|
|
if f.Name == "silo/" || strings.HasPrefix(f.Name, "silo/") {
|
|
continue
|
|
}
|
|
if err := copyZipEntry(zw, f); err != nil {
|
|
return nil, fmt.Errorf("kc: copying entry %s: %w", f.Name, err)
|
|
}
|
|
}
|
|
|
|
// Write new silo/ entries from PackInput.
|
|
if input.Manifest != nil {
|
|
if err := writeJSONEntry(zw, "silo/manifest.json", input.Manifest); err != nil {
|
|
return nil, fmt.Errorf("kc: writing manifest.json: %w", err)
|
|
}
|
|
}
|
|
if input.Metadata != nil {
|
|
if err := writeJSONEntry(zw, "silo/metadata.json", input.Metadata); err != nil {
|
|
return nil, fmt.Errorf("kc: writing metadata.json: %w", err)
|
|
}
|
|
}
|
|
if input.History != nil {
|
|
if err := writeJSONEntry(zw, "silo/history.json", input.History); err != nil {
|
|
return nil, fmt.Errorf("kc: writing history.json: %w", err)
|
|
}
|
|
}
|
|
if input.Dependencies != nil {
|
|
if err := writeJSONEntry(zw, "silo/dependencies.json", input.Dependencies); err != nil {
|
|
return nil, fmt.Errorf("kc: writing dependencies.json: %w", err)
|
|
}
|
|
}
|
|
if input.Approvals != nil {
|
|
if err := writeJSONEntry(zw, "silo/approvals.json", input.Approvals); err != nil {
|
|
return nil, fmt.Errorf("kc: writing approvals.json: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := zw.Close(); err != nil {
|
|
return nil, fmt.Errorf("kc: closing zip writer: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// copyZipEntry copies a single entry from the original ZIP to the new writer,
|
|
// preserving the file header (compression method, timestamps, etc.).
|
|
func copyZipEntry(zw *zip.Writer, f *zip.File) error {
|
|
header := f.FileHeader
|
|
w, err := zw.CreateHeader(&header)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rc.Close()
|
|
|
|
_, err = io.Copy(w, rc)
|
|
return err
|
|
}
|
|
|
|
// writeJSONEntry writes a new silo/ entry as JSON with Deflate compression.
|
|
func writeJSONEntry(zw *zip.Writer, name string, v any) error {
|
|
data, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
header := &zip.FileHeader{
|
|
Name: name,
|
|
Method: zip.Deflate,
|
|
}
|
|
w, err := zw.CreateHeader(header)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = w.Write(data)
|
|
return err
|
|
}
|