feat(revisions): auto-create revision on item metadata changes #177

Merged
forbes merged 1 commits from feat/auto-revision-on-update into main 2026-03-04 20:08:43 +00:00
2 changed files with 93 additions and 4 deletions

View File

@@ -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)

View File

@@ -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)