From 563eae48925981193e47fec5705953143f07af4c Mon Sep 17 00:00:00 2001 From: Forbes Date: Wed, 4 Mar 2026 13:59:24 -0600 Subject: [PATCH] feat(revisions): auto-create revision on item metadata changes When updating an item via PUT /api/items/{partNumber}, any change to metadata fields (part_number, item_type, description, sourcing_type, long_description) now creates a new revision for audit trail. Previously, revisions were only created when the 'properties' JSONB field was explicitly included in the request body. Metadata-only changes were invisible in revision history. New behavior: - Detect which metadata fields actually changed vs current values - If metadata changed without properties, carry forward properties from the latest revision and auto-generate a comment (e.g. 'updated description') - If properties changed, use the provided properties (existing behavior) - If both changed, create a single revision capturing both - If nothing actually changed (identical values), skip revision creation - Publish revision.created SSE event and trigger auto-jobs Also adds GetLatestRevision() to ItemRepository for efficiently fetching the current revision's properties without loading all revisions. Closes #173 --- internal/api/handlers.go | 65 +++++++++++++++++++++++++++++++++++++--- internal/db/items.go | 32 ++++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index a9331de..ca8e9fb 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -860,6 +860,7 @@ type UpdateItemRequest struct { } // HandleUpdateItem updates an item's fields and/or creates a new revision. +// Any change to item metadata or properties triggers a new revision for audit trail. func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) { ctx := r.Context() partNumber := chi.URLParam(r, "partNumber") @@ -900,6 +901,30 @@ func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) { fields.Description = req.Description } + // Detect which metadata fields actually changed + var metadataChanges []string + if req.PartNumber != "" && req.PartNumber != item.PartNumber { + metadataChanges = append(metadataChanges, "part_number") + } + if req.ItemType != "" && req.ItemType != item.ItemType { + metadataChanges = append(metadataChanges, "item_type") + } + if req.Description != "" && req.Description != item.Description { + metadataChanges = append(metadataChanges, "description") + } + if req.SourcingType != nil && *req.SourcingType != item.SourcingType { + metadataChanges = append(metadataChanges, "sourcing_type") + } + if req.LongDescription != nil { + oldLD := "" + if item.LongDescription != nil { + oldLD = *item.LongDescription + } + if *req.LongDescription != oldLD { + metadataChanges = append(metadataChanges, "long_description") + } + } + // Update the item record (UUID stays the same) if user := auth.UserFromContext(ctx); user != nil { fields.UpdatedBy = &user.Username @@ -910,12 +935,38 @@ func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) { return } - // Create new revision if properties provided - if req.Properties != nil { + // Create a revision if anything changed (metadata or properties) + metadataChanged := len(metadataChanges) > 0 + propertiesChanged := req.Properties != nil + + if metadataChanged || propertiesChanged { + // Determine properties for the new revision + props := req.Properties + if props == nil { + // Carry forward properties from the latest revision + latestRev, err := s.items.GetLatestRevision(ctx, item.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get latest revision") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get latest revision") + return + } + if latestRev != nil { + props = latestRev.Properties + } else { + props = make(map[string]any) + } + } + + // Auto-generate comment if not provided and only metadata changed + comment := req.Comment + if comment == "" && metadataChanged { + comment = "updated " + strings.Join(metadataChanges, ", ") + } + rev := &db.Revision{ ItemID: item.ID, - Properties: req.Properties, - Comment: &req.Comment, + Properties: props, + Comment: &comment, } if user := auth.UserFromContext(ctx); user != nil { rev.CreatedBy = &user.Username @@ -926,6 +977,12 @@ func (s *Server) HandleUpdateItem(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "revision_failed", err.Error()) return } + + s.broker.Publish("revision.created", mustMarshal(map[string]any{ + "part_number": fields.PartNumber, + "revision_number": rev.RevisionNumber, + })) + go s.triggerJobs(context.Background(), "revision_created", item.ID, item) } // Get updated item (use new part number if changed) diff --git a/internal/db/items.go b/internal/db/items.go index 1750b89..1a743e5 100644 --- a/internal/db/items.go +++ b/internal/db/items.go @@ -329,6 +329,38 @@ func (r *ItemRepository) CreateRevision(ctx context.Context, rev *Revision) erro return nil } +// GetLatestRevision retrieves the most recent revision for an item. +func (r *ItemRepository) GetLatestRevision(ctx context.Context, itemID string) (*Revision, error) { + rev := &Revision{} + var propsJSON []byte + + err := r.db.pool.QueryRow(ctx, ` + SELECT id, item_id, revision_number, properties, file_key, file_version, + file_checksum, file_size, COALESCE(file_storage_backend, 'filesystem'), + thumbnail_key, created_at, created_by, comment, + COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) as labels + FROM revisions + WHERE item_id = $1 + ORDER BY revision_number DESC + LIMIT 1 + `, itemID).Scan( + &rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion, + &rev.FileChecksum, &rev.FileSize, &rev.FileStorageBackend, + &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment, + &rev.Status, &rev.Labels, + ) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("querying latest revision: %w", err) + } + if err := json.Unmarshal(propsJSON, &rev.Properties); err != nil { + return nil, fmt.Errorf("unmarshaling properties: %w", err) + } + return rev, nil +} + // GetRevisions retrieves all revisions for an item. func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Revision, error) { // Check if status column exists (migration 007 applied) -- 2.49.1