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