package api import ( "context" "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "github.com/alexedwards/scs/v2" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/kindredsystems/silo/internal/auth" "github.com/kindredsystems/silo/internal/config" "github.com/kindredsystems/silo/internal/db" "github.com/kindredsystems/silo/internal/partnum" "github.com/kindredsystems/silo/internal/schema" "github.com/kindredsystems/silo/internal/storage" "github.com/rs/zerolog" "gopkg.in/yaml.v3" ) // Server holds dependencies for HTTP handlers. type Server struct { logger zerolog.Logger db *db.DB items *db.ItemRepository projects *db.ProjectRepository relationships *db.RelationshipRepository schemas map[string]*schema.Schema schemasDir string partgen *partnum.Generator storage *storage.Storage auth *auth.Service sessions *scs.SessionManager oidc *auth.OIDCBackend authConfig *config.AuthConfig itemFiles *db.ItemFileRepository broker *Broker serverState *ServerState } // NewServer creates a new API server. func NewServer( logger zerolog.Logger, database *db.DB, schemas map[string]*schema.Schema, schemasDir string, store *storage.Storage, authService *auth.Service, sessionManager *scs.SessionManager, oidcBackend *auth.OIDCBackend, authCfg *config.AuthConfig, broker *Broker, state *ServerState, ) *Server { items := db.NewItemRepository(database) projects := db.NewProjectRepository(database) relationships := db.NewRelationshipRepository(database) itemFiles := db.NewItemFileRepository(database) seqStore := &dbSequenceStore{db: database, schemas: schemas} partgen := partnum.NewGenerator(schemas, seqStore) return &Server{ logger: logger, db: database, items: items, projects: projects, relationships: relationships, schemas: schemas, schemasDir: schemasDir, partgen: partgen, storage: store, auth: authService, sessions: sessionManager, oidc: oidcBackend, authConfig: authCfg, itemFiles: itemFiles, broker: broker, serverState: state, } } // dbSequenceStore implements partnum.SequenceStore using the database. type dbSequenceStore struct { db *db.DB schemas map[string]*schema.Schema } func (s *dbSequenceStore) NextValue(ctx context.Context, schemaName string, scope string) (int, error) { // For now, use schema name as ID. In production, you'd look up the schema UUID. return s.db.NextSequenceValue(ctx, schemaName, scope) } // Error response structure. type ErrorResponse struct { Error string `json:"error"` Message string `json:"message,omitempty"` } // writeJSON writes a JSON response. func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } // writeError writes an error JSON response. func writeError(w http.ResponseWriter, status int, err string, message string) { writeJSON(w, status, ErrorResponse{Error: err, Message: message}) } // Health check handlers // HandleHealth returns basic health status. func (s *Server) HandleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{ "status": "ok", "mode": string(s.serverState.Mode()), }) } // HandleReady checks database and storage connectivity. func (s *Server) HandleReady(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Check database if err := s.db.Pool().Ping(ctx); err != nil { writeError(w, http.StatusServiceUnavailable, "database_unavailable", err.Error()) return } storageStatus := "ok" if s.storage != nil { if err := s.storage.Ping(ctx); err != nil { storageStatus = "unavailable" } } else { storageStatus = "not_configured" } writeJSON(w, http.StatusOK, map[string]any{ "status": "ready", "database": "ok", "storage": storageStatus, "mode": string(s.serverState.Mode()), "sse_clients": s.broker.ClientCount(), }) } // Schema handlers // SchemaResponse represents a schema in API responses. type SchemaResponse struct { Name string `json:"name"` Version int `json:"version"` Description string `json:"description"` Separator string `json:"separator"` Format string `json:"format"` Segments []SegmentResponse `json:"segments"` Examples []string `json:"examples,omitempty"` } // SegmentResponse represents a schema segment. type SegmentResponse struct { Name string `json:"name"` Type string `json:"type"` Description string `json:"description,omitempty"` Required bool `json:"required"` Values map[string]string `json:"values,omitempty"` Length int `json:"length,omitempty"` } // HandleListSchemas lists all available schemas. func (s *Server) HandleListSchemas(w http.ResponseWriter, r *http.Request) { schemas := make([]SchemaResponse, 0, len(s.schemas)) for _, sch := range s.schemas { schemas = append(schemas, schemaToResponse(sch)) } writeJSON(w, http.StatusOK, schemas) } // HandleGetSchema returns a specific schema. func (s *Server) HandleGetSchema(w http.ResponseWriter, r *http.Request) { name := chi.URLParam(r, "name") sch, ok := s.schemas[name] if !ok { writeError(w, http.StatusNotFound, "not_found", "Schema not found") return } writeJSON(w, http.StatusOK, schemaToResponse(sch)) } // FormFieldDescriptor describes a single field in the form descriptor response. type FormFieldDescriptor struct { Name string `json:"name"` Type string `json:"type"` Widget string `json:"widget,omitempty"` Label string `json:"label"` Required bool `json:"required,omitempty"` Default any `json:"default,omitempty"` Unit string `json:"unit,omitempty"` Description string `json:"description,omitempty"` Options []string `json:"options,omitempty"` Currency string `json:"currency,omitempty"` // Item-field specific DerivedFromCategory map[string]string `json:"derived_from_category,omitempty"` SearchEndpoint string `json:"search_endpoint,omitempty"` } // FormFieldGroupDescriptor describes an ordered group of resolved fields. type FormFieldGroupDescriptor struct { Key string `json:"key"` Label string `json:"label"` Order int `json:"order"` Fields []FormFieldDescriptor `json:"fields"` } // HandleGetFormDescriptor returns the full form descriptor for a schema. func (s *Server) HandleGetFormDescriptor(w http.ResponseWriter, r *http.Request) { schemaName := chi.URLParam(r, "name") sch, ok := s.schemas[schemaName] if !ok { writeError(w, http.StatusNotFound, "not_found", "Schema not found") return } result := map[string]any{ "schema_name": sch.Name, "format": sch.Format, } // Category picker with auto-derived values_by_domain if sch.UI != nil && sch.UI.CategoryPicker != nil { picker := map[string]any{ "style": sch.UI.CategoryPicker.Style, } vbd := sch.ValuesByDomain() stages := make([]map[string]any, 0, len(sch.UI.CategoryPicker.Stages)+1) for _, stage := range sch.UI.CategoryPicker.Stages { stg := map[string]any{ "name": stage.Name, "label": stage.Label, } if stage.Values != nil { stg["values"] = stage.Values } stages = append(stages, stg) } // Auto-add subcategory stage from values_by_domain if vbd != nil { stages = append(stages, map[string]any{ "name": "subcategory", "label": "Type", "values_by_domain": vbd, }) } picker["stages"] = stages result["category_picker"] = picker } // Item fields if sch.UI != nil && sch.UI.ItemFields != nil { result["item_fields"] = sch.UI.ItemFields } // Resolve field groups into ordered list with full field metadata if sch.UI != nil && sch.UI.FieldGroups != nil { groups := s.resolveFieldGroups(sch, sch.UI.FieldGroups) result["field_groups"] = groups } // Category field groups if sch.UI != nil && sch.UI.CategoryFieldGroups != nil { catGroups := make(map[string][]FormFieldGroupDescriptor) for prefix, groups := range sch.UI.CategoryFieldGroups { catGroups[prefix] = s.resolveCategoryFieldGroups(sch, prefix, groups) } result["category_field_groups"] = catGroups } // Field overrides (pass through) if sch.UI != nil && sch.UI.FieldOverrides != nil { result["field_overrides"] = sch.UI.FieldOverrides } writeJSON(w, http.StatusOK, result) } // resolveFieldGroups converts field group definitions into fully resolved descriptors. func (s *Server) resolveFieldGroups(sch *schema.Schema, groups map[string]schema.FieldGroup) []FormFieldGroupDescriptor { result := make([]FormFieldGroupDescriptor, 0, len(groups)) for key, group := range groups { desc := FormFieldGroupDescriptor{ Key: key, Label: group.Label, Order: group.Order, } for _, fieldName := range group.Fields { fd := s.resolveField(sch, fieldName) desc.Fields = append(desc.Fields, fd) } result = append(result, desc) } // Sort by order sort.Slice(result, func(i, j int) bool { return result[i].Order < result[j].Order }) return result } // resolveCategoryFieldGroups resolves category-specific field groups. func (s *Server) resolveCategoryFieldGroups(sch *schema.Schema, prefix string, groups map[string]schema.FieldGroup) []FormFieldGroupDescriptor { result := make([]FormFieldGroupDescriptor, 0, len(groups)) for key, group := range groups { desc := FormFieldGroupDescriptor{ Key: key, Label: group.Label, Order: group.Order, } for _, fieldName := range group.Fields { fd := s.resolveCategoryField(sch, prefix, fieldName) desc.Fields = append(desc.Fields, fd) } result = append(result, desc) } sort.Slice(result, func(i, j int) bool { return result[i].Order < result[j].Order }) return result } // resolveField builds a FormFieldDescriptor from item_fields or property_schemas.defaults. func (s *Server) resolveField(sch *schema.Schema, name string) FormFieldDescriptor { fd := FormFieldDescriptor{Name: name} // Check item_fields first if sch.UI != nil && sch.UI.ItemFields != nil { if def, ok := sch.UI.ItemFields[name]; ok { fd.Type = def.Type fd.Widget = def.Widget fd.Label = def.Label fd.Required = def.Required fd.Default = def.Default fd.Options = def.Options fd.DerivedFromCategory = def.DerivedFromCategory fd.SearchEndpoint = def.SearchEndpoint s.applyOverrides(sch, name, &fd) return fd } } // Check property_schemas.defaults if sch.PropertySchemas != nil && sch.PropertySchemas.Defaults != nil { if def, ok := sch.PropertySchemas.Defaults[name]; ok { fd.Type = def.Type fd.Label = name // Use field name as label if not overridden fd.Default = def.Default fd.Unit = def.Unit fd.Description = def.Description fd.Required = def.Required s.applyOverrides(sch, name, &fd) return fd } } // Fallback — field name only fd.Label = name fd.Type = "string" s.applyOverrides(sch, name, &fd) return fd } // resolveCategoryField builds a FormFieldDescriptor from category-specific property schema. func (s *Server) resolveCategoryField(sch *schema.Schema, prefix, name string) FormFieldDescriptor { fd := FormFieldDescriptor{Name: name, Label: name, Type: "string"} if sch.PropertySchemas != nil { if catProps, ok := sch.PropertySchemas.Categories[prefix]; ok { if def, ok := catProps[name]; ok { fd.Type = def.Type fd.Default = def.Default fd.Unit = def.Unit fd.Description = def.Description fd.Required = def.Required } } } s.applyOverrides(sch, name, &fd) return fd } // applyOverrides applies field_overrides to a field descriptor. func (s *Server) applyOverrides(sch *schema.Schema, name string, fd *FormFieldDescriptor) { if sch.UI == nil || sch.UI.FieldOverrides == nil { return } ov, ok := sch.UI.FieldOverrides[name] if !ok { return } if ov.Widget != "" { fd.Widget = ov.Widget } if ov.Currency != "" { fd.Currency = ov.Currency } if len(ov.Options) > 0 { fd.Options = ov.Options } } func schemaToResponse(sch *schema.Schema) SchemaResponse { segments := make([]SegmentResponse, len(sch.Segments)) for i, seg := range sch.Segments { segments[i] = SegmentResponse{ Name: seg.Name, Type: seg.Type, Description: seg.Description, Required: seg.Required, Values: seg.Values, Length: seg.Length, } } return SchemaResponse{ Name: sch.Name, Version: sch.Version, Description: sch.Description, Separator: sch.Separator, Format: sch.Format, Segments: segments, Examples: sch.Examples, } } // Item handlers // ItemResponse represents an item in API responses. type ItemResponse struct { ID string `json:"id"` PartNumber string `json:"part_number"` ItemType string `json:"item_type"` Description string `json:"description"` CurrentRevision int `json:"current_revision"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` SourcingType string `json:"sourcing_type"` LongDescription *string `json:"long_description,omitempty"` ThumbnailKey *string `json:"thumbnail_key,omitempty"` FileCount int `json:"file_count"` FilesTotalSize int64 `json:"files_total_size"` Properties map[string]any `json:"properties,omitempty"` } // CreateItemRequest represents a request to create an item. type CreateItemRequest struct { Schema string `json:"schema"` Category string `json:"category"` Description string `json:"description"` Projects []string `json:"projects,omitempty"` Properties map[string]any `json:"properties,omitempty"` SourcingType string `json:"sourcing_type,omitempty"` LongDescription *string `json:"long_description,omitempty"` } // HandleListItems lists items with optional filtering. func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) { ctx := r.Context() opts := db.ListOptions{ ItemType: r.URL.Query().Get("type"), Search: r.URL.Query().Get("search"), Project: r.URL.Query().Get("project"), } if limit := r.URL.Query().Get("limit"); limit != "" { if l, err := strconv.Atoi(limit); err == nil { opts.Limit = l } } if offset := r.URL.Query().Get("offset"); offset != "" { if o, err := strconv.Atoi(offset); err == nil { opts.Offset = o } } items, err := s.items.List(ctx, opts) if err != nil { s.logger.Error().Err(err).Msg("failed to list items") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list items") return } // Batch-fetch file attachment stats ids := make([]string, len(items)) for i, item := range items { ids[i] = item.ID } fileStats, _ := s.items.BatchGetFileStats(ctx, ids) response := make([]ItemResponse, len(items)) for i, item := range items { response[i] = itemToResponse(item) if fs, ok := fileStats[item.ID]; ok { response[i].FileCount = fs.Count response[i].FilesTotalSize = fs.TotalSize } } writeJSON(w, http.StatusOK, response) } // HandleFuzzySearch performs fuzzy search across items. func (s *Server) HandleFuzzySearch(w http.ResponseWriter, r *http.Request) { ctx := r.Context() q := r.URL.Query().Get("q") if q == "" { writeJSON(w, http.StatusOK, []FuzzyResult{}) return } fieldsParam := r.URL.Query().Get("fields") var fields []string if fieldsParam != "" { fields = strings.Split(fieldsParam, ",") } limit := 50 if l := r.URL.Query().Get("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed } } // Pre-filter by type and project via SQL (no search term) opts := db.ListOptions{ ItemType: r.URL.Query().Get("type"), Project: r.URL.Query().Get("project"), Limit: 500, // reasonable upper bound for fuzzy matching } items, err := s.items.List(ctx, opts) if err != nil { s.logger.Error().Err(err).Msg("failed to list items for fuzzy search") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to search items") return } responses := make([]ItemResponse, len(items)) for i, item := range items { responses[i] = itemToResponse(item) } results := FuzzySearch(q, responses, fields, limit) writeJSON(w, http.StatusOK, results) } // HandleCreateItem creates a new item with generated part number. func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var req CreateItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) return } // Default schema schemaName := req.Schema if schemaName == "" { schemaName = "kindred-rd" } // Generate part number (no longer includes project) input := partnum.Input{ SchemaName: schemaName, Values: map[string]string{ "category": req.Category, }, } // Determine item type from category itemType := "part" if len(req.Category) > 0 { switch req.Category[0] { case 'A': itemType = "assembly" case 'T': itemType = "tooling" } } properties := req.Properties if properties == nil { properties = make(map[string]any) } properties["category"] = req.Category // Retry loop: if the generated part number collides with an existing // item (sequence counter out of sync), generate a new one and retry. const maxRetries = 5 var item *db.Item for attempt := 0; attempt < maxRetries; attempt++ { partNumber, err := s.partgen.Generate(ctx, input) if err != nil { s.logger.Error().Err(err).Msg("failed to generate part number") writeError(w, http.StatusBadRequest, "generation_failed", err.Error()) return } item = &db.Item{ PartNumber: partNumber, ItemType: itemType, Description: req.Description, SourcingType: req.SourcingType, LongDescription: req.LongDescription, } if user := auth.UserFromContext(ctx); user != nil { item.CreatedBy = &user.Username } err = s.items.Create(ctx, item, properties) if err == nil { break // success } // Check if this is a duplicate key error — retry with next sequence var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { s.logger.Warn(). Str("part_number", partNumber). Int("attempt", attempt+1). Msg("duplicate part number, retrying with next sequence value") continue } // Non-duplicate error, fail immediately s.logger.Error().Err(err).Msg("failed to create item") writeError(w, http.StatusInternalServerError, "create_failed", err.Error()) return } if item == nil || item.ID == "" { s.logger.Error().Int("retries", maxRetries).Msg("exhausted retries for part number generation") writeError(w, http.StatusConflict, "duplicate_part_number", "Could not generate a unique part number after multiple attempts") return } // Tag item with projects if provided if len(req.Projects) > 0 { for _, projectCode := range req.Projects { if err := s.projects.AddItemToProjectByCode(ctx, item.ID, projectCode); err != nil { s.logger.Warn().Err(err).Str("project", projectCode).Msg("failed to tag item with project") } } } resp := itemToResponse(item) writeJSON(w, http.StatusCreated, resp) s.broker.Publish("item.created", mustMarshal(resp)) } // HandleGetItemByUUID retrieves an item by its stable UUID (the items.id column). // Used by silo-mod to resolve FreeCAD document SiloUUID properties to part numbers. func (s *Server) HandleGetItemByUUID(w http.ResponseWriter, r *http.Request) { ctx := r.Context() uuid := chi.URLParam(r, "uuid") item, err := s.items.GetByID(ctx, uuid) if err != nil { s.logger.Error().Err(err).Msg("failed to get item by UUID") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") return } if item == nil || item.ArchivedAt != nil { writeError(w, http.StatusNotFound, "not_found", "Item not found") return } response := itemToResponse(item) if fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}); err == nil { if fs, ok := fileStats[item.ID]; ok { response.FileCount = fs.Count response.FilesTotalSize = fs.TotalSize } } writeJSON(w, http.StatusOK, response) } // HandleGetItem retrieves an item by part number. // Supports query param: ?include=properties to include current revision properties. func (s *Server) HandleGetItem(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 } response := itemToResponse(item) // File attachment stats if fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}); err == nil { if fs, ok := fileStats[item.ID]; ok { response.FileCount = fs.Count response.FilesTotalSize = fs.TotalSize } } // Include properties from current revision if requested if r.URL.Query().Get("include") == "properties" { revisions, err := s.items.GetRevisions(ctx, item.ID) if err == nil { for _, rev := range revisions { if rev.RevisionNumber == item.CurrentRevision { response.Properties = rev.Properties break } } } } writeJSON(w, http.StatusOK, response) } // UpdateItemRequest represents a request to update an item. type UpdateItemRequest struct { PartNumber string `json:"part_number,omitempty"` ItemType string `json:"item_type,omitempty"` Description string `json:"description,omitempty"` Properties map[string]any `json:"properties,omitempty"` Comment string `json:"comment,omitempty"` SourcingType *string `json:"sourcing_type,omitempty"` LongDescription *string `json:"long_description,omitempty"` } // HandleUpdateItem updates an item's fields and/or creates a new revision. func (s *Server) HandleUpdateItem(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 UpdateItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) return } // Update item fields if provided fields := db.UpdateItemFields{ PartNumber: item.PartNumber, ItemType: item.ItemType, Description: item.Description, SourcingType: req.SourcingType, LongDescription: req.LongDescription, } if req.PartNumber != "" { fields.PartNumber = req.PartNumber } if req.ItemType != "" { fields.ItemType = req.ItemType } if req.Description != "" { fields.Description = req.Description } // Update the item record (UUID stays the same) if user := auth.UserFromContext(ctx); user != nil { fields.UpdatedBy = &user.Username } if err := s.items.Update(ctx, item.ID, fields); err != nil { s.logger.Error().Err(err).Msg("failed to update item") writeError(w, http.StatusInternalServerError, "update_failed", err.Error()) return } // Create new revision if properties provided if req.Properties != nil { rev := &db.Revision{ ItemID: item.ID, Properties: req.Properties, Comment: &req.Comment, } if user := auth.UserFromContext(ctx); user != nil { rev.CreatedBy = &user.Username } if err := s.items.CreateRevision(ctx, rev); err != nil { s.logger.Error().Err(err).Msg("failed to create revision") writeError(w, http.StatusInternalServerError, "revision_failed", err.Error()) return } } // Get updated item (use new part number if changed) item, _ = s.items.GetByPartNumber(ctx, fields.PartNumber) resp := itemToResponse(item) writeJSON(w, http.StatusOK, resp) s.broker.Publish("item.updated", mustMarshal(resp)) } // HandleDeleteItem permanently deletes an item. // Use query param ?soft=true for soft delete (archive). func (s *Server) HandleDeleteItem(w http.ResponseWriter, r *http.Request) { ctx := r.Context() partNumber := chi.URLParam(r, "partNumber") soft := r.URL.Query().Get("soft") == "true" 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 } if soft { if err := s.items.Archive(ctx, item.ID); err != nil { s.logger.Error().Err(err).Msg("failed to archive item") writeError(w, http.StatusInternalServerError, "archive_failed", err.Error()) return } } else { if err := s.items.Delete(ctx, item.ID); err != nil { s.logger.Error().Err(err).Msg("failed to delete item") writeError(w, http.StatusInternalServerError, "delete_failed", err.Error()) return } } w.WriteHeader(http.StatusNoContent) s.broker.Publish("item.deleted", mustMarshal(map[string]string{"part_number": partNumber})) } // Revision handlers // RevisionResponse represents a revision in API responses. type RevisionResponse struct { ID string `json:"id"` RevisionNumber int `json:"revision_number"` Properties map[string]any `json:"properties"` FileKey *string `json:"file_key,omitempty"` FileChecksum *string `json:"file_checksum,omitempty"` FileSize *int64 `json:"file_size,omitempty"` CreatedAt string `json:"created_at"` CreatedBy *string `json:"created_by,omitempty"` Comment *string `json:"comment,omitempty"` Status string `json:"status"` Labels []string `json:"labels"` } // RevisionDiffResponse represents the API response for revision comparison. type RevisionDiffResponse struct { FromRevision int `json:"from_revision"` ToRevision int `json:"to_revision"` FromStatus string `json:"from_status"` ToStatus string `json:"to_status"` FileChanged bool `json:"file_changed"` FileSizeDiff *int64 `json:"file_size_diff,omitempty"` Added map[string]any `json:"added,omitempty"` Removed map[string]any `json:"removed,omitempty"` Changed map[string]db.PropertyChange `json:"changed,omitempty"` } // UpdateRevisionRequest represents a request to update revision status/labels. type UpdateRevisionRequest struct { Status *string `json:"status,omitempty"` Labels []string `json:"labels,omitempty"` } // RollbackRequest represents a request to rollback to a previous revision. type RollbackRequest struct { Comment string `json:"comment,omitempty"` } // HandleListRevisions lists revisions for an item. func (s *Server) HandleListRevisions(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 } revisions, err := s.items.GetRevisions(ctx, item.ID) if err != nil { s.logger.Error().Err(err).Msg("failed to get revisions") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get revisions") return } response := make([]RevisionResponse, len(revisions)) for i, rev := range revisions { response[i] = revisionToResponse(rev) } writeJSON(w, http.StatusOK, response) } // HandleGetRevision retrieves a specific revision. func (s *Server) HandleGetRevision(w http.ResponseWriter, r *http.Request) { ctx := r.Context() partNumber := chi.URLParam(r, "partNumber") revStr := chi.URLParam(r, "revision") revNum, err := strconv.Atoi(revStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid_revision", "Revision must be a number") return } item, err := s.items.GetByPartNumber(ctx, partNumber) if err != nil || item == nil { writeError(w, http.StatusNotFound, "not_found", "Item not found") return } revisions, err := s.items.GetRevisions(ctx, item.ID) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get revisions") return } for _, rev := range revisions { if rev.RevisionNumber == revNum { writeJSON(w, http.StatusOK, revisionToResponse(rev)) return } } writeError(w, http.StatusNotFound, "not_found", "Revision not found") } // HandleUpdateRevision updates the status and/or labels of a revision. func (s *Server) HandleUpdateRevision(w http.ResponseWriter, r *http.Request) { ctx := r.Context() partNumber := chi.URLParam(r, "partNumber") revStr := chi.URLParam(r, "revision") revNum, err := strconv.Atoi(revStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid_revision", "Revision must be a number") return } 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 UpdateRevisionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) return } // Validate that at least one field is being updated if req.Status == nil && req.Labels == nil { writeError(w, http.StatusBadRequest, "invalid_request", "Must provide status or labels to update") return } err = s.items.UpdateRevisionStatus(ctx, item.ID, revNum, req.Status, req.Labels) if err != nil { if err.Error() == "revision not found" { writeError(w, http.StatusNotFound, "not_found", "Revision not found") return } s.logger.Error().Err(err).Msg("failed to update revision") writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) return } // Return updated revision rev, err := s.items.GetRevision(ctx, item.ID, revNum) if err != nil || rev == nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get updated revision") return } writeJSON(w, http.StatusOK, revisionToResponse(rev)) } // HandleCompareRevisions compares two revisions and returns their differences. func (s *Server) HandleCompareRevisions(w http.ResponseWriter, r *http.Request) { ctx := r.Context() partNumber := chi.URLParam(r, "partNumber") // Get query parameters for from and to revisions fromStr := r.URL.Query().Get("from") toStr := r.URL.Query().Get("to") if fromStr == "" || toStr == "" { writeError(w, http.StatusBadRequest, "invalid_request", "Must provide 'from' and 'to' query parameters") return } fromRev, err := strconv.Atoi(fromStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid_revision", "'from' must be a number") return } toRev, err := strconv.Atoi(toStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid_revision", "'to' must be a number") return } 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 } diff, err := s.items.CompareRevisions(ctx, item.ID, fromRev, toRev) if err != nil { s.logger.Error().Err(err).Msg("failed to compare revisions") writeError(w, http.StatusBadRequest, "comparison_failed", err.Error()) return } response := RevisionDiffResponse{ FromRevision: diff.FromRevision, ToRevision: diff.ToRevision, FromStatus: diff.FromStatus, ToStatus: diff.ToStatus, FileChanged: diff.FileChanged, FileSizeDiff: diff.FileSizeDiff, Added: diff.Added, Removed: diff.Removed, Changed: diff.Changed, } writeJSON(w, http.StatusOK, response) } // HandleRollbackRevision creates a new revision by copying from an existing one. func (s *Server) HandleRollbackRevision(w http.ResponseWriter, r *http.Request) { ctx := r.Context() partNumber := chi.URLParam(r, "partNumber") revStr := chi.URLParam(r, "revision") revNum, err := strconv.Atoi(revStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid_revision", "Revision must be a number") return } 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 RollbackRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" { writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) return } // Generate comment if not provided comment := req.Comment if comment == "" { comment = fmt.Sprintf("Rollback to revision %d", revNum) } var createdBy *string if user := auth.UserFromContext(ctx); user != nil { createdBy = &user.Username } newRev, err := s.items.CreateRevisionFromExisting(ctx, item.ID, revNum, comment, createdBy) if err != nil { s.logger.Error().Err(err).Msg("failed to create rollback revision") writeError(w, http.StatusBadRequest, "rollback_failed", err.Error()) return } s.logger.Info(). Str("part_number", partNumber). Int("source_revision", revNum). Int("new_revision", newRev.RevisionNumber). Msg("rollback revision created") writeJSON(w, http.StatusCreated, revisionToResponse(newRev)) s.broker.Publish("revision.created", mustMarshal(map[string]any{ "part_number": partNumber, "revision_number": newRev.RevisionNumber, "rollback_from": revNum, })) } // Part number generation // GeneratePartNumberRequest represents a request to generate a part number. type GeneratePartNumberRequest struct { Schema string `json:"schema"` Project string `json:"project"` Category string `json:"category"` } // HandleGeneratePartNumber generates a part number without creating an item. func (s *Server) HandleGeneratePartNumber(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var req GeneratePartNumberRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) return } schemaName := req.Schema if schemaName == "" { schemaName = "kindred-rd" } input := partnum.Input{ SchemaName: schemaName, Values: map[string]string{ "project": req.Project, "category": req.Category, }, } partNumber, err := s.partgen.Generate(ctx, input) if err != nil { writeError(w, http.StatusBadRequest, "generation_failed", err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"part_number": partNumber}) } // Schema value management handlers // AddSchemaValueRequest represents a request to add a new enum value. type AddSchemaValueRequest struct { Code string `json:"code"` Description string `json:"description"` } // UpdateSchemaValueRequest represents a request to update an enum value description. type UpdateSchemaValueRequest struct { Description string `json:"description"` } // HandleAddSchemaValue adds a new value to an enum segment. func (s *Server) HandleAddSchemaValue(w http.ResponseWriter, r *http.Request) { schemaName := chi.URLParam(r, "name") segmentName := chi.URLParam(r, "segment") sch, ok := s.schemas[schemaName] if !ok { writeError(w, http.StatusNotFound, "not_found", "Schema not found") return } // Find the segment var segment *schema.Segment for i := range sch.Segments { if sch.Segments[i].Name == segmentName { segment = &sch.Segments[i] break } } if segment == nil { writeError(w, http.StatusNotFound, "not_found", "Segment not found") return } if segment.Type != "enum" { writeError(w, http.StatusBadRequest, "invalid_segment", "Segment is not an enum type") return } var req AddSchemaValueRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) return } if req.Code == "" || req.Description == "" { writeError(w, http.StatusBadRequest, "invalid_request", "Code and description are required") return } // Check if code already exists if _, exists := segment.Values[req.Code]; exists { writeError(w, http.StatusConflict, "already_exists", "Value code already exists") return } // Add the new value segment.Values[req.Code] = req.Description // Save to file if err := s.saveSchema(sch); err != nil { s.logger.Error().Err(err).Msg("failed to save schema") writeError(w, http.StatusInternalServerError, "save_failed", err.Error()) return } writeJSON(w, http.StatusCreated, map[string]string{"code": req.Code, "description": req.Description}) } // HandleUpdateSchemaValue updates an enum value's description. func (s *Server) HandleUpdateSchemaValue(w http.ResponseWriter, r *http.Request) { schemaName := chi.URLParam(r, "name") segmentName := chi.URLParam(r, "segment") code := chi.URLParam(r, "code") sch, ok := s.schemas[schemaName] if !ok { writeError(w, http.StatusNotFound, "not_found", "Schema not found") return } // Find the segment var segment *schema.Segment for i := range sch.Segments { if sch.Segments[i].Name == segmentName { segment = &sch.Segments[i] break } } if segment == nil { writeError(w, http.StatusNotFound, "not_found", "Segment not found") return } if segment.Type != "enum" { writeError(w, http.StatusBadRequest, "invalid_segment", "Segment is not an enum type") return } // Check if code exists if _, exists := segment.Values[code]; !exists { writeError(w, http.StatusNotFound, "not_found", "Value code not found") return } var req UpdateSchemaValueRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) return } if req.Description == "" { writeError(w, http.StatusBadRequest, "invalid_request", "Description is required") return } // Update the value segment.Values[code] = req.Description // Save to file if err := s.saveSchema(sch); err != nil { s.logger.Error().Err(err).Msg("failed to save schema") writeError(w, http.StatusInternalServerError, "save_failed", err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"code": code, "description": req.Description}) } // HandleDeleteSchemaValue removes an enum value. func (s *Server) HandleDeleteSchemaValue(w http.ResponseWriter, r *http.Request) { schemaName := chi.URLParam(r, "name") segmentName := chi.URLParam(r, "segment") code := chi.URLParam(r, "code") sch, ok := s.schemas[schemaName] if !ok { writeError(w, http.StatusNotFound, "not_found", "Schema not found") return } // Find the segment var segment *schema.Segment for i := range sch.Segments { if sch.Segments[i].Name == segmentName { segment = &sch.Segments[i] break } } if segment == nil { writeError(w, http.StatusNotFound, "not_found", "Segment not found") return } if segment.Type != "enum" { writeError(w, http.StatusBadRequest, "invalid_segment", "Segment is not an enum type") return } // Check if code exists if _, exists := segment.Values[code]; !exists { writeError(w, http.StatusNotFound, "not_found", "Value code not found") return } // Delete the value delete(segment.Values, code) // Save to file if err := s.saveSchema(sch); err != nil { s.logger.Error().Err(err).Msg("failed to save schema") writeError(w, http.StatusInternalServerError, "save_failed", err.Error()) return } w.WriteHeader(http.StatusNoContent) } // saveSchema writes the schema back to its YAML file. func (s *Server) saveSchema(sch *schema.Schema) error { // Build the schema file structure schemaFile := schema.SchemaFile{ Schema: *sch, } data, err := yaml.Marshal(schemaFile) if err != nil { return err } filename := filepath.Join(s.schemasDir, sch.Name+".yaml") return os.WriteFile(filename, data, 0644) } // Helper functions func itemToResponse(item *db.Item) ItemResponse { return ItemResponse{ ID: item.ID, PartNumber: item.PartNumber, ItemType: item.ItemType, Description: item.Description, CurrentRevision: item.CurrentRevision, CreatedAt: item.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), SourcingType: item.SourcingType, LongDescription: item.LongDescription, ThumbnailKey: item.ThumbnailKey, } } func revisionToResponse(rev *db.Revision) RevisionResponse { labels := rev.Labels if labels == nil { labels = []string{} } return RevisionResponse{ ID: rev.ID, RevisionNumber: rev.RevisionNumber, Properties: rev.Properties, FileKey: rev.FileKey, FileChecksum: rev.FileChecksum, FileSize: rev.FileSize, CreatedAt: rev.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), CreatedBy: rev.CreatedBy, Comment: rev.Comment, Status: rev.Status, Labels: labels, } } // File upload/download handlers // CreateRevisionRequest represents a request to create a new revision. type CreateRevisionRequest struct { Properties map[string]any `json:"properties"` Comment string `json:"comment"` } // HandleCreateRevision creates a new revision for an item (without file). func (s *Server) HandleCreateRevision(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 CreateRevisionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) return } rev := &db.Revision{ ItemID: item.ID, Properties: req.Properties, Comment: &req.Comment, } if user := auth.UserFromContext(ctx); user != nil { rev.CreatedBy = &user.Username } if err := s.items.CreateRevision(ctx, rev); err != nil { s.logger.Error().Err(err).Msg("failed to create revision") writeError(w, http.StatusInternalServerError, "revision_failed", err.Error()) return } writeJSON(w, http.StatusCreated, revisionToResponse(rev)) s.broker.Publish("revision.created", mustMarshal(map[string]any{ "part_number": partNumber, "revision_number": rev.RevisionNumber, })) } // HandleUploadFile uploads a file and creates a new revision. func (s *Server) HandleUploadFile(w http.ResponseWriter, r *http.Request) { ctx := r.Context() partNumber := chi.URLParam(r, "partNumber") // Check storage is configured if s.storage == nil { writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured") return } 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 } // Parse multipart form (max 100MB) if err := r.ParseMultipartForm(100 << 20); err != nil { writeError(w, http.StatusBadRequest, "invalid_form", err.Error()) return } // Get the file file, header, err := r.FormFile("file") if err != nil { writeError(w, http.StatusBadRequest, "missing_file", "File is required") return } defer file.Close() // Get optional fields comment := r.FormValue("comment") propertiesJSON := r.FormValue("properties") var properties map[string]any if propertiesJSON != "" { if err := json.Unmarshal([]byte(propertiesJSON), &properties); err != nil { writeError(w, http.StatusBadRequest, "invalid_properties", "Properties must be valid JSON") return } } else { properties = make(map[string]any) } // Determine the next revision number nextRevision := item.CurrentRevision + 1 // Generate storage key fileKey := storage.FileKey(partNumber, nextRevision) // Determine content type contentType := header.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } // Upload to storage result, err := s.storage.Put(ctx, fileKey, file, header.Size, contentType) if err != nil { s.logger.Error().Err(err).Msg("failed to upload file") writeError(w, http.StatusInternalServerError, "upload_failed", err.Error()) return } // Create revision with file metadata rev := &db.Revision{ ItemID: item.ID, Properties: properties, FileKey: &result.Key, FileVersion: &result.VersionID, FileChecksum: &result.Checksum, FileSize: &result.Size, Comment: &comment, } if user := auth.UserFromContext(ctx); user != nil { rev.CreatedBy = &user.Username } if err := s.items.CreateRevision(ctx, rev); err != nil { s.logger.Error().Err(err).Msg("failed to create revision") writeError(w, http.StatusInternalServerError, "revision_failed", err.Error()) return } s.logger.Info(). Str("part_number", partNumber). Int("revision", rev.RevisionNumber). Str("file_key", fileKey). Int64("size", result.Size). Msg("file uploaded") writeJSON(w, http.StatusCreated, revisionToResponse(rev)) } // HandleDownloadFile downloads the file for a specific revision. func (s *Server) HandleDownloadFile(w http.ResponseWriter, r *http.Request) { ctx := r.Context() partNumber := chi.URLParam(r, "partNumber") revStr := chi.URLParam(r, "revision") // Check storage is configured if s.storage == nil { writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured") return } item, err := s.items.GetByPartNumber(ctx, partNumber) if err != nil || item == nil { writeError(w, http.StatusNotFound, "not_found", "Item not found") return } // Parse revision number (or use "latest") var revNum int if revStr == "latest" { revNum = item.CurrentRevision } else { revNum, err = strconv.Atoi(revStr) if err != nil { writeError(w, http.StatusBadRequest, "invalid_revision", "Revision must be a number or 'latest'") return } } // Get revision to find file key revisions, err := s.items.GetRevisions(ctx, item.ID) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get revisions") return } var revision *db.Revision for _, rev := range revisions { if rev.RevisionNumber == revNum { revision = rev break } } if revision == nil { writeError(w, http.StatusNotFound, "not_found", "Revision not found") return } if revision.FileKey == nil { writeError(w, http.StatusNotFound, "no_file", "Revision has no associated file") return } // Get file from storage var reader interface { Read(p []byte) (n int, err error) Close() error } if revision.FileVersion != nil && *revision.FileVersion != "" { reader, err = s.storage.GetVersion(ctx, *revision.FileKey, *revision.FileVersion) } else { reader, err = s.storage.Get(ctx, *revision.FileKey) } if err != nil { s.logger.Error().Err(err).Str("key", *revision.FileKey).Msg("failed to get file") writeError(w, http.StatusInternalServerError, "download_failed", err.Error()) return } defer reader.Close() // Set response headers filename := partNumber + "_rev" + strconv.Itoa(revNum) + ".FCStd" w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") if revision.FileSize != nil { w.Header().Set("Content-Length", strconv.FormatInt(*revision.FileSize, 10)) } // Stream file to response buf := make([]byte, 32*1024) for { n, readErr := reader.Read(buf) if n > 0 { if _, writeErr := w.Write(buf[:n]); writeErr != nil { s.logger.Error().Err(writeErr).Msg("failed to write response") return } } if readErr != nil { break } } } // HandleDownloadLatestFile downloads the file for the latest revision. func (s *Server) HandleDownloadLatestFile(w http.ResponseWriter, r *http.Request) { chi.URLParam(r, "partNumber") // ensure URL param is consumed r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chi.RouteContext(r.Context()))) // Add "latest" as the revision param and delegate rctx := chi.RouteContext(r.Context()) rctx.URLParams.Add("revision", "latest") s.HandleDownloadFile(w, r) } // Project handlers // ProjectResponse represents a project in API responses. type ProjectResponse struct { ID string `json:"id"` Code string `json:"code"` Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` CreatedAt string `json:"created_at"` } // CreateProjectRequest represents a request to create a project. type CreateProjectRequest struct { Code string `json:"code"` Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` } // UpdateProjectRequest represents a request to update a project. type UpdateProjectRequest struct { Name string `json:"name"` Description string `json:"description"` } // HandleListProjects lists all projects. func (s *Server) HandleListProjects(w http.ResponseWriter, r *http.Request) { ctx := r.Context() projects, err := s.projects.List(ctx) if err != nil { s.logger.Error().Err(err).Msg("failed to list projects") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list projects") return } response := make([]ProjectResponse, len(projects)) for i, p := range projects { response[i] = projectToResponse(p) } writeJSON(w, http.StatusOK, response) } // HandleCreateProject creates a new project. func (s *Server) HandleCreateProject(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var req CreateProjectRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) return } if req.Code == "" { writeError(w, http.StatusBadRequest, "invalid_request", "Project code is required") return } // Validate project code format (2-10 alphanumeric characters) if len(req.Code) < 2 || len(req.Code) > 10 { writeError(w, http.StatusBadRequest, "invalid_code", "Project code must be 2-10 characters") return } project := &db.Project{ Code: req.Code, Name: req.Name, Description: req.Description, } if user := auth.UserFromContext(ctx); user != nil { project.CreatedBy = &user.Username } if err := s.projects.Create(ctx, project); err != nil { s.logger.Error().Err(err).Msg("failed to create project") writeError(w, http.StatusInternalServerError, "create_failed", err.Error()) return } writeJSON(w, http.StatusCreated, projectToResponse(project)) } // HandleGetProject retrieves a project by code. func (s *Server) HandleGetProject(w http.ResponseWriter, r *http.Request) { ctx := r.Context() code := chi.URLParam(r, "code") project, err := s.projects.GetByCode(ctx, code) if err != nil { s.logger.Error().Err(err).Msg("failed to get project") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get project") return } if project == nil { writeError(w, http.StatusNotFound, "not_found", "Project not found") return } writeJSON(w, http.StatusOK, projectToResponse(project)) } // HandleUpdateProject updates a project. func (s *Server) HandleUpdateProject(w http.ResponseWriter, r *http.Request) { ctx := r.Context() code := chi.URLParam(r, "code") var req UpdateProjectRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) return } if err := s.projects.Update(ctx, code, req.Name, req.Description); err != nil { s.logger.Error().Err(err).Msg("failed to update project") writeError(w, http.StatusInternalServerError, "update_failed", err.Error()) return } project, _ := s.projects.GetByCode(ctx, code) writeJSON(w, http.StatusOK, projectToResponse(project)) } // HandleDeleteProject deletes a project. func (s *Server) HandleDeleteProject(w http.ResponseWriter, r *http.Request) { ctx := r.Context() code := chi.URLParam(r, "code") if err := s.projects.Delete(ctx, code); err != nil { s.logger.Error().Err(err).Msg("failed to delete project") writeError(w, http.StatusInternalServerError, "delete_failed", err.Error()) return } w.WriteHeader(http.StatusNoContent) } // HandleGetProjectItems lists items in a project. func (s *Server) HandleGetProjectItems(w http.ResponseWriter, r *http.Request) { ctx := r.Context() code := chi.URLParam(r, "code") project, err := s.projects.GetByCode(ctx, code) if err != nil || project == nil { writeError(w, http.StatusNotFound, "not_found", "Project not found") return } items, err := s.projects.GetItemsForProject(ctx, project.ID) if err != nil { s.logger.Error().Err(err).Msg("failed to get project items") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get items") return } response := make([]ItemResponse, len(items)) for i, item := range items { response[i] = itemToResponse(item) } writeJSON(w, http.StatusOK, response) } // HandleGetItemProjects lists projects for an item. func (s *Server) HandleGetItemProjects(w http.ResponseWriter, r *http.Request) { ctx := r.Context() partNumber := chi.URLParam(r, "partNumber") item, err := s.items.GetByPartNumber(ctx, partNumber) if err != nil || item == nil { writeError(w, http.StatusNotFound, "not_found", "Item not found") return } projects, err := s.projects.GetProjectsForItem(ctx, item.ID) if err != nil { s.logger.Error().Err(err).Msg("failed to get item projects") writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get projects") return } response := make([]ProjectResponse, len(projects)) for i, p := range projects { response[i] = projectToResponse(p) } writeJSON(w, http.StatusOK, response) } // AddItemProjectRequest represents a request to add projects to an item. type AddItemProjectRequest struct { Projects []string `json:"projects"` } // HandleAddItemProjects adds project tags to an item. func (s *Server) HandleAddItemProjects(w http.ResponseWriter, r *http.Request) { ctx := r.Context() partNumber := chi.URLParam(r, "partNumber") item, err := s.items.GetByPartNumber(ctx, partNumber) if err != nil || item == nil { writeError(w, http.StatusNotFound, "not_found", "Item not found") return } var req AddItemProjectRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) return } for _, code := range req.Projects { if err := s.projects.AddItemToProjectByCode(ctx, item.ID, code); err != nil { s.logger.Warn().Err(err).Str("project", code).Msg("failed to add project") } } // Return updated project list projects, _ := s.projects.GetProjectsForItem(ctx, item.ID) response := make([]ProjectResponse, len(projects)) for i, p := range projects { response[i] = projectToResponse(p) } writeJSON(w, http.StatusOK, response) } // HandleRemoveItemProject removes a project tag from an item. func (s *Server) HandleRemoveItemProject(w http.ResponseWriter, r *http.Request) { ctx := r.Context() partNumber := chi.URLParam(r, "partNumber") projectCode := chi.URLParam(r, "code") item, err := s.items.GetByPartNumber(ctx, partNumber) if err != nil || item == nil { writeError(w, http.StatusNotFound, "not_found", "Item not found") return } if err := s.projects.RemoveItemFromProjectByCode(ctx, item.ID, projectCode); err != nil { s.logger.Error().Err(err).Msg("failed to remove project") writeError(w, http.StatusInternalServerError, "remove_failed", err.Error()) return } w.WriteHeader(http.StatusNoContent) } func projectToResponse(p *db.Project) ProjectResponse { return ProjectResponse{ ID: p.ID, Code: p.Code, Name: p.Name, Description: p.Description, CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), } }