Add revision control and project tagging migration
This commit is contained in:
@@ -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, `
|
||||
|
||||
Reference in New Issue
Block a user