- Add MacroFile type to internal/kc and extract silo/macros/* files
from .kc ZIP archives on commit
- Create ItemMacroRepository with ReplaceForItem, ListByItem, and
GetByFilename methods
- Add GET /{partNumber}/macros (list) and
GET /{partNumber}/macros/{filename} (source content) endpoints
- Index macros in extractKCMetadata with SSE broadcast
- List endpoint omits content for lightweight responses
Closes #144
473 lines
14 KiB
Go
473 lines
14 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"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/kc"
|
|
)
|
|
|
|
// validTransitions defines allowed lifecycle state transitions for Phase 1.
|
|
var validTransitions = map[string][]string{
|
|
"draft": {"review"},
|
|
"review": {"draft", "released"},
|
|
"released": {"obsolete"},
|
|
"obsolete": {},
|
|
}
|
|
|
|
// MetadataResponse is the JSON representation returned by GET /metadata.
|
|
type MetadataResponse struct {
|
|
SchemaName *string `json:"schema_name"`
|
|
LifecycleState string `json:"lifecycle_state"`
|
|
Tags []string `json:"tags"`
|
|
Fields map[string]any `json:"fields"`
|
|
Manifest *ManifestInfo `json:"manifest,omitempty"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
UpdatedBy *string `json:"updated_by,omitempty"`
|
|
}
|
|
|
|
// ManifestInfo is the manifest subset included in MetadataResponse.
|
|
type ManifestInfo struct {
|
|
UUID *string `json:"uuid,omitempty"`
|
|
SiloInstance *string `json:"silo_instance,omitempty"`
|
|
RevisionHash *string `json:"revision_hash,omitempty"`
|
|
KCVersion *string `json:"kc_version,omitempty"`
|
|
}
|
|
|
|
func metadataToResponse(m *db.ItemMetadata) MetadataResponse {
|
|
resp := MetadataResponse{
|
|
SchemaName: m.SchemaName,
|
|
LifecycleState: m.LifecycleState,
|
|
Tags: m.Tags,
|
|
Fields: m.Fields,
|
|
UpdatedAt: m.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
UpdatedBy: m.UpdatedBy,
|
|
}
|
|
if m.ManifestUUID != nil || m.SiloInstance != nil || m.RevisionHash != nil || m.KCVersion != nil {
|
|
resp.Manifest = &ManifestInfo{
|
|
UUID: m.ManifestUUID,
|
|
SiloInstance: m.SiloInstance,
|
|
RevisionHash: m.RevisionHash,
|
|
KCVersion: m.KCVersion,
|
|
}
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// HandleGetMetadata returns indexed metadata for an item.
|
|
// GET /api/items/{partNumber}/metadata
|
|
func (s *Server) HandleGetMetadata(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
|
|
}
|
|
|
|
meta, err := s.metadata.Get(ctx, item.ID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get metadata")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata")
|
|
return
|
|
}
|
|
if meta == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, metadataToResponse(meta))
|
|
}
|
|
|
|
// HandleUpdateMetadata merges fields into the metadata JSONB.
|
|
// PUT /api/items/{partNumber}/metadata
|
|
func (s *Server) HandleUpdateMetadata(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 struct {
|
|
Fields map[string]any `json:"fields"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
|
return
|
|
}
|
|
if len(req.Fields) == 0 {
|
|
writeError(w, http.StatusBadRequest, "invalid_body", "Fields must not be empty")
|
|
return
|
|
}
|
|
|
|
username := ""
|
|
if user := auth.UserFromContext(ctx); user != nil {
|
|
username = user.Username
|
|
}
|
|
|
|
if err := s.metadata.UpdateFields(ctx, item.ID, req.Fields, username); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to update metadata fields")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update metadata")
|
|
return
|
|
}
|
|
|
|
meta, err := s.metadata.Get(ctx, item.ID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to read back metadata")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to read metadata")
|
|
return
|
|
}
|
|
|
|
s.broker.Publish("metadata.updated", mustMarshal(map[string]any{
|
|
"part_number": partNumber,
|
|
"changed_fields": fieldKeys(req.Fields),
|
|
"lifecycle_state": meta.LifecycleState,
|
|
"updated_by": username,
|
|
}))
|
|
|
|
writeJSON(w, http.StatusOK, metadataToResponse(meta))
|
|
}
|
|
|
|
// HandleUpdateLifecycle transitions the lifecycle state.
|
|
// PATCH /api/items/{partNumber}/metadata/lifecycle
|
|
func (s *Server) HandleUpdateLifecycle(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 struct {
|
|
State string `json:"state"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
|
return
|
|
}
|
|
if req.State == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_body", "State is required")
|
|
return
|
|
}
|
|
|
|
meta, err := s.metadata.Get(ctx, item.ID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get metadata")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata")
|
|
return
|
|
}
|
|
if meta == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item")
|
|
return
|
|
}
|
|
|
|
// Validate transition
|
|
allowed := validTransitions[meta.LifecycleState]
|
|
valid := false
|
|
for _, s := range allowed {
|
|
if s == req.State {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
if !valid {
|
|
writeError(w, http.StatusUnprocessableEntity, "invalid_transition",
|
|
"Cannot transition from '"+meta.LifecycleState+"' to '"+req.State+"'")
|
|
return
|
|
}
|
|
|
|
username := ""
|
|
if user := auth.UserFromContext(ctx); user != nil {
|
|
username = user.Username
|
|
}
|
|
|
|
fromState := meta.LifecycleState
|
|
if err := s.metadata.UpdateLifecycle(ctx, item.ID, req.State, username); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to update lifecycle")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update lifecycle")
|
|
return
|
|
}
|
|
|
|
s.broker.Publish("metadata.lifecycle", mustMarshal(map[string]any{
|
|
"part_number": partNumber,
|
|
"from_state": fromState,
|
|
"to_state": req.State,
|
|
"updated_by": username,
|
|
}))
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"lifecycle_state": req.State})
|
|
}
|
|
|
|
// HandleUpdateTags adds/removes tags.
|
|
// PATCH /api/items/{partNumber}/metadata/tags
|
|
func (s *Server) HandleUpdateTags(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 struct {
|
|
Add []string `json:"add"`
|
|
Remove []string `json:"remove"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body")
|
|
return
|
|
}
|
|
if len(req.Add) == 0 && len(req.Remove) == 0 {
|
|
writeError(w, http.StatusBadRequest, "invalid_body", "Must provide 'add' or 'remove'")
|
|
return
|
|
}
|
|
|
|
meta, err := s.metadata.Get(ctx, item.ID)
|
|
if err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to get metadata")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get metadata")
|
|
return
|
|
}
|
|
if meta == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "No metadata indexed for this item")
|
|
return
|
|
}
|
|
|
|
// Compute new tag set: (existing + add) - remove
|
|
tagSet := make(map[string]struct{})
|
|
for _, t := range meta.Tags {
|
|
tagSet[t] = struct{}{}
|
|
}
|
|
for _, t := range req.Add {
|
|
tagSet[t] = struct{}{}
|
|
}
|
|
removeSet := make(map[string]struct{})
|
|
for _, t := range req.Remove {
|
|
removeSet[t] = struct{}{}
|
|
}
|
|
var newTags []string
|
|
for t := range tagSet {
|
|
if _, removed := removeSet[t]; !removed {
|
|
newTags = append(newTags, t)
|
|
}
|
|
}
|
|
if newTags == nil {
|
|
newTags = []string{}
|
|
}
|
|
|
|
username := ""
|
|
if user := auth.UserFromContext(ctx); user != nil {
|
|
username = user.Username
|
|
}
|
|
|
|
if err := s.metadata.SetTags(ctx, item.ID, newTags, username); err != nil {
|
|
s.logger.Error().Err(err).Msg("failed to update tags")
|
|
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update tags")
|
|
return
|
|
}
|
|
|
|
s.broker.Publish("metadata.tags", mustMarshal(map[string]any{
|
|
"part_number": partNumber,
|
|
"added": req.Add,
|
|
"removed": req.Remove,
|
|
}))
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"tags": newTags})
|
|
}
|
|
|
|
// extractKCMetadata attempts to extract and index silo/ metadata from an
|
|
// uploaded .kc file. Failures are logged but non-fatal for Phase 1.
|
|
func (s *Server) extractKCMetadata(ctx context.Context, item *db.Item, fileKey string, rev *db.Revision) {
|
|
if s.storage == nil {
|
|
return
|
|
}
|
|
|
|
reader, err := s.storage.Get(ctx, fileKey)
|
|
if err != nil {
|
|
s.logger.Warn().Err(err).Str("file_key", fileKey).Msg("kc: failed to read back file for extraction")
|
|
return
|
|
}
|
|
defer reader.Close()
|
|
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
s.logger.Warn().Err(err).Msg("kc: failed to read file bytes")
|
|
return
|
|
}
|
|
|
|
result, err := kc.Extract(data)
|
|
if err != nil {
|
|
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: extraction failed")
|
|
return
|
|
}
|
|
if result == nil {
|
|
return // plain .fcstd, no silo/ directory
|
|
}
|
|
|
|
// Validate manifest UUID matches item
|
|
if result.Manifest != nil && result.Manifest.UUID != "" && result.Manifest.UUID != item.ID {
|
|
s.logger.Warn().
|
|
Str("manifest_uuid", result.Manifest.UUID).
|
|
Str("item_id", item.ID).
|
|
Msg("kc: manifest UUID does not match item, skipping indexing")
|
|
return
|
|
}
|
|
|
|
// Check for no-op (revision_hash unchanged)
|
|
if result.Manifest != nil && result.Manifest.RevisionHash != "" {
|
|
existing, _ := s.metadata.Get(ctx, item.ID)
|
|
if existing != nil && existing.RevisionHash != nil && *existing.RevisionHash == result.Manifest.RevisionHash {
|
|
s.logger.Debug().Str("part_number", item.PartNumber).Msg("kc: revision_hash unchanged, skipping")
|
|
return
|
|
}
|
|
}
|
|
|
|
username := ""
|
|
if rev.CreatedBy != nil {
|
|
username = *rev.CreatedBy
|
|
}
|
|
|
|
meta := &db.ItemMetadata{
|
|
ItemID: item.ID,
|
|
LifecycleState: "draft",
|
|
Fields: make(map[string]any),
|
|
Tags: []string{},
|
|
UpdatedBy: strPtr(username),
|
|
}
|
|
|
|
if result.Manifest != nil {
|
|
meta.KCVersion = strPtr(result.Manifest.KCVersion)
|
|
meta.ManifestUUID = strPtr(result.Manifest.UUID)
|
|
meta.SiloInstance = strPtr(result.Manifest.SiloInstance)
|
|
meta.RevisionHash = strPtr(result.Manifest.RevisionHash)
|
|
}
|
|
|
|
if result.Metadata != nil {
|
|
meta.SchemaName = strPtr(result.Metadata.SchemaName)
|
|
if result.Metadata.Tags != nil {
|
|
meta.Tags = result.Metadata.Tags
|
|
}
|
|
if result.Metadata.LifecycleState != "" {
|
|
meta.LifecycleState = result.Metadata.LifecycleState
|
|
}
|
|
if result.Metadata.Fields != nil {
|
|
meta.Fields = result.Metadata.Fields
|
|
}
|
|
}
|
|
|
|
if err := s.metadata.Upsert(ctx, meta); err != nil {
|
|
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to upsert metadata")
|
|
return
|
|
}
|
|
|
|
s.broker.Publish("metadata.updated", mustMarshal(map[string]any{
|
|
"part_number": item.PartNumber,
|
|
"lifecycle_state": meta.LifecycleState,
|
|
"updated_by": username,
|
|
}))
|
|
|
|
// Index dependencies from silo/dependencies.json.
|
|
if result.Dependencies != nil {
|
|
dbDeps := make([]*db.ItemDependency, len(result.Dependencies))
|
|
for i, d := range result.Dependencies {
|
|
pn := d.PartNumber
|
|
rev := d.Revision
|
|
qty := d.Quantity
|
|
label := d.Label
|
|
rel := d.Relationship
|
|
if rel == "" {
|
|
rel = "component"
|
|
}
|
|
dbDeps[i] = &db.ItemDependency{
|
|
ParentItemID: item.ID,
|
|
ChildUUID: d.UUID,
|
|
ChildPartNumber: &pn,
|
|
ChildRevision: &rev,
|
|
Quantity: &qty,
|
|
Label: &label,
|
|
Relationship: rel,
|
|
}
|
|
}
|
|
if err := s.deps.ReplaceForRevision(ctx, item.ID, rev.RevisionNumber, dbDeps); err != nil {
|
|
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to index dependencies")
|
|
} else {
|
|
s.broker.Publish("dependencies.changed", mustMarshal(map[string]any{
|
|
"part_number": item.PartNumber,
|
|
"count": len(dbDeps),
|
|
}))
|
|
}
|
|
}
|
|
|
|
// Index macros from silo/macros/*.
|
|
if len(result.Macros) > 0 {
|
|
dbMacros := make([]*db.ItemMacro, len(result.Macros))
|
|
for i, m := range result.Macros {
|
|
dbMacros[i] = &db.ItemMacro{
|
|
ItemID: item.ID,
|
|
Filename: m.Filename,
|
|
Trigger: "manual",
|
|
Content: m.Content,
|
|
}
|
|
}
|
|
if err := s.macros.ReplaceForItem(ctx, item.ID, rev.RevisionNumber, dbMacros); err != nil {
|
|
s.logger.Warn().Err(err).Str("part_number", item.PartNumber).Msg("kc: failed to index macros")
|
|
} else {
|
|
s.broker.Publish("macros.changed", mustMarshal(map[string]any{
|
|
"part_number": item.PartNumber,
|
|
"count": len(dbMacros),
|
|
}))
|
|
}
|
|
}
|
|
|
|
s.logger.Info().Str("part_number", item.PartNumber).Msg("kc: metadata indexed successfully")
|
|
}
|
|
|
|
// strPtr returns a pointer to s, or nil if s is empty.
|
|
func strPtr(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|
|
|
|
// fieldKeys returns the keys from a map.
|
|
func fieldKeys(m map[string]any) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|