Add revision control and project tagging migration

This commit is contained in:
Forbes
2026-01-24 16:27:18 -06:00
parent c327baf36f
commit b396097715
11 changed files with 2941 additions and 229 deletions

View File

@@ -38,6 +38,35 @@ type Revision struct {
CreatedAt time.Time
CreatedBy *string
Comment *string
Status string // draft, review, released, obsolete
Labels []string // arbitrary tags
}
// RevisionStatus constants
const (
RevisionStatusDraft = "draft"
RevisionStatusReview = "review"
RevisionStatusReleased = "released"
RevisionStatusObsolete = "obsolete"
)
// PropertyChange represents a change in a property value between revisions.
type PropertyChange struct {
OldValue any `json:"old_value"`
NewValue any `json:"new_value"`
}
// RevisionDiff represents the differences between two revisions.
type RevisionDiff 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]PropertyChange `json:"changed,omitempty"`
}
// ItemRepository provides item database operations.
@@ -131,35 +160,53 @@ func (r *ItemRepository) GetByID(ctx context.Context, id string) (*Item, error)
// List retrieves items with optional filtering.
func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, error) {
query := `
SELECT id, part_number, schema_id, item_type, description,
created_at, updated_at, archived_at, current_revision
FROM items
WHERE archived_at IS NULL
`
// Build query - use JOIN if filtering by project
var query string
args := []any{}
argNum := 1
if opts.Project != "" {
// Filter by project via many-to-many relationship
query = `
SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description,
i.created_at, i.updated_at, i.archived_at, i.current_revision
FROM items i
JOIN item_projects ip ON ip.item_id = i.id
JOIN projects p ON p.id = ip.project_id
WHERE i.archived_at IS NULL AND p.code = $1
`
args = append(args, opts.Project)
argNum++
} else {
query = `
SELECT id, part_number, schema_id, item_type, description,
created_at, updated_at, archived_at, current_revision
FROM items
WHERE archived_at IS NULL
`
}
if opts.ItemType != "" {
query += fmt.Sprintf(" AND item_type = $%d", argNum)
args = append(args, opts.ItemType)
argNum++
}
if opts.Project != "" {
// Filter by project code (first 5 characters of part number)
query += fmt.Sprintf(" AND part_number LIKE $%d", argNum)
args = append(args, opts.Project+"%")
argNum++
}
if opts.Search != "" {
query += fmt.Sprintf(" AND (part_number ILIKE $%d OR description ILIKE $%d)", argNum, argNum)
if opts.Project != "" {
query += fmt.Sprintf(" AND (i.part_number ILIKE $%d OR i.description ILIKE $%d)", argNum, argNum)
} else {
query += fmt.Sprintf(" AND (part_number ILIKE $%d OR description ILIKE $%d)", argNum, argNum)
}
args = append(args, "%"+opts.Search+"%")
argNum++
}
query += " ORDER BY part_number"
if opts.Project != "" {
query += " ORDER BY i.part_number"
} else {
query += " ORDER BY part_number"
}
if opts.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argNum)
@@ -194,13 +241,11 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e
return items, nil
}
// ListProjects returns distinct project codes from all items.
// ListProjects returns all project codes from the projects table.
// Deprecated: Use ProjectRepository.List() instead for full project details.
func (r *ItemRepository) ListProjects(ctx context.Context) ([]string, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT DISTINCT SUBSTRING(part_number FROM 1 FOR 5) as project_code
FROM items
WHERE archived_at IS NULL
ORDER BY project_code
SELECT code FROM projects ORDER BY code
`)
if err != nil {
return nil, fmt.Errorf("querying projects: %w", err)
@@ -255,13 +300,37 @@ func (r *ItemRepository) CreateRevision(ctx context.Context, rev *Revision) erro
// GetRevisions retrieves all revisions for an item.
func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Revision, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, thumbnail_key, created_at, created_by, comment
FROM revisions
WHERE item_id = $1
ORDER BY revision_number DESC
`, itemID)
// Check if status column exists (migration 007 applied)
var hasStatusColumn bool
err := r.db.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'revisions' AND column_name = 'status'
)
`).Scan(&hasStatusColumn)
if err != nil {
return nil, fmt.Errorf("checking schema: %w", err)
}
var rows pgx.Rows
if hasStatusColumn {
rows, err = r.db.pool.Query(ctx, `
SELECT id, item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, 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
`, itemID)
} else {
rows, err = r.db.pool.Query(ctx, `
SELECT id, item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, thumbnail_key, created_at, created_by, comment
FROM revisions
WHERE item_id = $1
ORDER BY revision_number DESC
`, itemID)
}
if err != nil {
return nil, fmt.Errorf("querying revisions: %w", err)
}
@@ -271,10 +340,20 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
for rows.Next() {
rev := &Revision{}
var propsJSON []byte
err := rows.Scan(
&rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion,
&rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment,
)
if hasStatusColumn {
err = rows.Scan(
&rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion,
&rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment,
&rev.Status, &rev.Labels,
)
} else {
err = rows.Scan(
&rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion,
&rev.FileChecksum, &rev.FileSize, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment,
)
rev.Status = "draft"
rev.Labels = []string{}
}
if err != nil {
return nil, fmt.Errorf("scanning revision: %w", err)
}
@@ -287,6 +366,228 @@ func (r *ItemRepository) GetRevisions(ctx context.Context, itemID string) ([]*Re
return revisions, nil
}
// GetRevision retrieves a specific revision by item ID and revision number.
func (r *ItemRepository) GetRevision(ctx context.Context, itemID string, revisionNumber int) (*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, thumbnail_key, created_at, created_by, comment,
COALESCE(status, 'draft') as status, COALESCE(labels, '{}') as labels
FROM revisions
WHERE item_id = $1 AND revision_number = $2
`, itemID, revisionNumber).Scan(
&rev.ID, &rev.ItemID, &rev.RevisionNumber, &propsJSON, &rev.FileKey, &rev.FileVersion,
&rev.FileChecksum, &rev.FileSize, &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 revision: %w", err)
}
if err := json.Unmarshal(propsJSON, &rev.Properties); err != nil {
return nil, fmt.Errorf("unmarshaling properties: %w", err)
}
return rev, nil
}
// UpdateRevisionStatus updates the status and/or labels of a revision.
func (r *ItemRepository) UpdateRevisionStatus(ctx context.Context, itemID string, revisionNumber int, status *string, labels []string) error {
if status == nil && labels == nil {
return nil // Nothing to update
}
if status != nil {
// Validate status
switch *status {
case RevisionStatusDraft, RevisionStatusReview, RevisionStatusReleased, RevisionStatusObsolete:
// Valid
default:
return fmt.Errorf("invalid status: %s", *status)
}
}
// Build dynamic update query
query := "UPDATE revisions SET "
args := []any{}
argNum := 1
updates := []string{}
if status != nil {
updates = append(updates, fmt.Sprintf("status = $%d", argNum))
args = append(args, *status)
argNum++
}
if labels != nil {
updates = append(updates, fmt.Sprintf("labels = $%d", argNum))
args = append(args, labels)
argNum++
}
query += updates[0]
for i := 1; i < len(updates); i++ {
query += ", " + updates[i]
}
query += fmt.Sprintf(" WHERE item_id = $%d AND revision_number = $%d", argNum, argNum+1)
args = append(args, itemID, revisionNumber)
result, err := r.db.pool.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("updating revision: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("revision not found")
}
return nil
}
// CompareRevisions computes the differences between two revisions.
func (r *ItemRepository) CompareRevisions(ctx context.Context, itemID string, fromRev, toRev int) (*RevisionDiff, error) {
// Get both revisions
from, err := r.GetRevision(ctx, itemID, fromRev)
if err != nil {
return nil, fmt.Errorf("getting from revision: %w", err)
}
if from == nil {
return nil, fmt.Errorf("revision %d not found", fromRev)
}
to, err := r.GetRevision(ctx, itemID, toRev)
if err != nil {
return nil, fmt.Errorf("getting to revision: %w", err)
}
if to == nil {
return nil, fmt.Errorf("revision %d not found", toRev)
}
diff := &RevisionDiff{
FromRevision: fromRev,
ToRevision: toRev,
FromStatus: from.Status,
ToStatus: to.Status,
Added: make(map[string]any),
Removed: make(map[string]any),
Changed: make(map[string]PropertyChange),
}
// Check file changes
fromChecksum := ""
toChecksum := ""
if from.FileChecksum != nil {
fromChecksum = *from.FileChecksum
}
if to.FileChecksum != nil {
toChecksum = *to.FileChecksum
}
diff.FileChanged = fromChecksum != toChecksum
// Calculate file size difference
if from.FileSize != nil && to.FileSize != nil {
sizeDiff := *to.FileSize - *from.FileSize
diff.FileSizeDiff = &sizeDiff
}
// Compare properties
// Find added and changed properties
for key, toVal := range to.Properties {
fromVal, exists := from.Properties[key]
if !exists {
diff.Added[key] = toVal
} else if !equalValues(fromVal, toVal) {
diff.Changed[key] = PropertyChange{
OldValue: fromVal,
NewValue: toVal,
}
}
}
// Find removed properties
for key, fromVal := range from.Properties {
if _, exists := to.Properties[key]; !exists {
diff.Removed[key] = fromVal
}
}
// Clean up empty maps for cleaner JSON
if len(diff.Added) == 0 {
diff.Added = nil
}
if len(diff.Removed) == 0 {
diff.Removed = nil
}
if len(diff.Changed) == 0 {
diff.Changed = nil
}
return diff, nil
}
// equalValues compares two property values for equality.
func equalValues(a, b any) bool {
// Use JSON encoding for deep comparison
aJSON, err1 := json.Marshal(a)
bJSON, err2 := json.Marshal(b)
if err1 != nil || err2 != nil {
return false
}
return string(aJSON) == string(bJSON)
}
// CreateRevisionFromExisting creates a new revision by copying from an existing one (rollback).
func (r *ItemRepository) CreateRevisionFromExisting(ctx context.Context, itemID string, sourceRevNumber int, comment string, createdBy *string) (*Revision, error) {
// Get the source revision
source, err := r.GetRevision(ctx, itemID, sourceRevNumber)
if err != nil {
return nil, fmt.Errorf("getting source revision: %w", err)
}
if source == nil {
return nil, fmt.Errorf("source revision %d not found", sourceRevNumber)
}
// Create new revision with copied properties (and optionally file reference)
newRev := &Revision{
ItemID: itemID,
Properties: source.Properties,
FileKey: source.FileKey,
FileVersion: source.FileVersion,
FileChecksum: source.FileChecksum,
FileSize: source.FileSize,
ThumbnailKey: source.ThumbnailKey,
CreatedBy: createdBy,
Comment: &comment,
}
// Insert the new revision
propsJSON, err := json.Marshal(newRev.Properties)
if err != nil {
return nil, fmt.Errorf("marshaling properties: %w", err)
}
err = r.db.pool.QueryRow(ctx, `
INSERT INTO revisions (
item_id, revision_number, properties, file_key, file_version,
file_checksum, file_size, thumbnail_key, created_by, comment, status
)
SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9, 'draft'
FROM items WHERE id = $1
RETURNING id, revision_number, created_at
`, newRev.ItemID, propsJSON, newRev.FileKey, newRev.FileVersion,
newRev.FileChecksum, newRev.FileSize, newRev.ThumbnailKey, newRev.CreatedBy, newRev.Comment,
).Scan(&newRev.ID, &newRev.RevisionNumber, &newRev.CreatedAt)
if err != nil {
return nil, fmt.Errorf("inserting revision: %w", err)
}
newRev.Status = RevisionStatusDraft
return newRev, nil
}
// Archive soft-deletes an item.
func (r *ItemRepository) Archive(ctx context.Context, id string) error {
_, err := r.db.pool.Exec(ctx, `