package db import ( "context" "encoding/json" "fmt" "time" "github.com/jackc/pgx/v5" ) // Item represents an item in the database. type Item struct { ID string PartNumber string SchemaID *string ItemType string Description string CreatedAt time.Time UpdatedAt time.Time ArchivedAt *time.Time CurrentRevision int CADSyncedAt *time.Time CADFilePath *string CreatedBy *string UpdatedBy *string SourcingType string // "manufactured" or "purchased" LongDescription *string // extended description ThumbnailKey *string // MinIO key for item thumbnail } // Revision represents a revision record. type Revision struct { ID string ItemID string RevisionNumber int Properties map[string]any FileKey *string FileVersion *string FileChecksum *string FileSize *int64 FileStorageBackend string // "minio" or "filesystem" ThumbnailKey *string 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. type ItemRepository struct { db *DB } // NewItemRepository creates a new item repository. func NewItemRepository(db *DB) *ItemRepository { return &ItemRepository{db: db} } // Create inserts a new item and its initial revision. func (r *ItemRepository) Create(ctx context.Context, item *Item, properties map[string]any) error { return r.db.Tx(ctx, func(tx pgx.Tx) error { // Insert item sourcingType := item.SourcingType if sourcingType == "" { sourcingType = "manufactured" } err := tx.QueryRow(ctx, ` INSERT INTO items (part_number, schema_id, item_type, description, created_by, sourcing_type, long_description) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at, current_revision `, item.PartNumber, item.SchemaID, item.ItemType, item.Description, item.CreatedBy, sourcingType, item.LongDescription, ).Scan( &item.ID, &item.CreatedAt, &item.UpdatedAt, &item.CurrentRevision, ) if err != nil { return fmt.Errorf("inserting item: %w", err) } // Insert initial revision propsJSON, err := json.Marshal(properties) if err != nil { return fmt.Errorf("marshaling properties: %w", err) } _, err = tx.Exec(ctx, ` INSERT INTO revisions (item_id, revision_number, properties, created_by) VALUES ($1, 1, $2, $3) `, item.ID, propsJSON, item.CreatedBy) if err != nil { return fmt.Errorf("inserting revision: %w", err) } return nil }) } // GetByPartNumber retrieves an item by part number. func (r *ItemRepository) GetByPartNumber(ctx context.Context, partNumber string) (*Item, error) { item := &Item{} err := r.db.pool.QueryRow(ctx, ` SELECT id, part_number, schema_id, item_type, description, created_at, updated_at, archived_at, current_revision, cad_synced_at, cad_file_path, sourcing_type, long_description, thumbnail_key FROM items WHERE part_number = $1 AND archived_at IS NULL `, partNumber).Scan( &item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description, &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.CADSyncedAt, &item.CADFilePath, &item.SourcingType, &item.LongDescription, &item.ThumbnailKey, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("querying item: %w", err) } return item, nil } // GetByID retrieves an item by ID. func (r *ItemRepository) GetByID(ctx context.Context, id string) (*Item, error) { item := &Item{} err := r.db.pool.QueryRow(ctx, ` SELECT id, part_number, schema_id, item_type, description, created_at, updated_at, archived_at, current_revision, cad_synced_at, cad_file_path, sourcing_type, long_description, thumbnail_key FROM items WHERE id = $1 `, id).Scan( &item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description, &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.CADSyncedAt, &item.CADFilePath, &item.SourcingType, &item.LongDescription, &item.ThumbnailKey, ) if err == pgx.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("querying item: %w", err) } return item, nil } // List retrieves items with optional filtering. func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, error) { // 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, i.sourcing_type, i.long_description, i.thumbnail_key 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, sourcing_type, long_description, thumbnail_key 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.Search != "" { 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++ } 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) args = append(args, opts.Limit) argNum++ } if opts.Offset > 0 { query += fmt.Sprintf(" OFFSET $%d", argNum) args = append(args, opts.Offset) } rows, err := r.db.pool.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("querying items: %w", err) } defer rows.Close() var items []*Item for rows.Next() { item := &Item{} err := rows.Scan( &item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description, &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.SourcingType, &item.LongDescription, &item.ThumbnailKey, ) if err != nil { return nil, fmt.Errorf("scanning item: %w", err) } items = append(items, item) } return items, nil } // 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 code FROM projects ORDER BY code `) if err != nil { return nil, fmt.Errorf("querying projects: %w", err) } defer rows.Close() var projects []string for rows.Next() { var code string if err := rows.Scan(&code); err != nil { return nil, fmt.Errorf("scanning project: %w", err) } projects = append(projects, code) } return projects, nil } // ListOptions configures item listing. type ListOptions struct { ItemType string Search string Project string Limit int Offset int } // CreateRevision adds a new revision for an item. func (r *ItemRepository) CreateRevision(ctx context.Context, rev *Revision) error { propsJSON, err := json.Marshal(rev.Properties) if err != nil { return fmt.Errorf("marshaling properties: %w", err) } if rev.FileStorageBackend == "" { rev.FileStorageBackend = "minio" } err = r.db.pool.QueryRow(ctx, ` INSERT INTO revisions ( item_id, revision_number, properties, file_key, file_version, file_checksum, file_size, file_storage_backend, thumbnail_key, created_by, comment ) SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9, $10 FROM items WHERE id = $1 RETURNING id, revision_number, created_at `, rev.ItemID, propsJSON, rev.FileKey, rev.FileVersion, rev.FileChecksum, rev.FileSize, rev.FileStorageBackend, rev.ThumbnailKey, rev.CreatedBy, rev.Comment, ).Scan(&rev.ID, &rev.RevisionNumber, &rev.CreatedAt) if err != nil { return fmt.Errorf("inserting revision: %w", err) } return 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) 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, COALESCE(file_storage_backend, 'minio'), 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) } defer rows.Close() var revisions []*Revision for rows.Next() { rev := &Revision{} var propsJSON []byte if hasStatusColumn { err = rows.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, ) } 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{} rev.FileStorageBackend = "minio" } if err != nil { return nil, fmt.Errorf("scanning revision: %w", err) } if err := json.Unmarshal(propsJSON, &rev.Properties); err != nil { return nil, fmt.Errorf("unmarshaling properties: %w", err) } revisions = append(revisions, rev) } 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) { // 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) } rev := &Revision{} var propsJSON []byte if hasStatusColumn { 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, 'minio'), thumbnail_key, created_at, created_by, comment, COALESCE(status, 'draft') as status, COALESCE(labels, ARRAY[]::TEXT[]) 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.FileStorageBackend, &rev.ThumbnailKey, &rev.CreatedAt, &rev.CreatedBy, &rev.Comment, &rev.Status, &rev.Labels, ) } else { 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 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 = "draft" rev.Labels = []string{} rev.FileStorageBackend = "minio" } 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, FileStorageBackend: source.FileStorageBackend, 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, file_storage_backend, thumbnail_key, created_by, comment, status ) SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'draft' FROM items WHERE id = $1 RETURNING id, revision_number, created_at `, newRev.ItemID, propsJSON, newRev.FileKey, newRev.FileVersion, newRev.FileChecksum, newRev.FileSize, newRev.FileStorageBackend, 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, ` UPDATE items SET archived_at = now() WHERE id = $1 `, id) return err } // UpdateItemFields holds the fields that can be updated on an item. type UpdateItemFields struct { PartNumber string ItemType string Description string UpdatedBy *string SourcingType *string LongDescription *string } // Update modifies an item's fields. The UUID remains stable. func (r *ItemRepository) Update(ctx context.Context, id string, fields UpdateItemFields) error { _, err := r.db.pool.Exec(ctx, ` UPDATE items SET part_number = $2, item_type = $3, description = $4, updated_by = $5, sourcing_type = COALESCE($6, sourcing_type), long_description = CASE WHEN $7::boolean THEN $8 ELSE long_description END, updated_at = now() WHERE id = $1 AND archived_at IS NULL `, id, fields.PartNumber, fields.ItemType, fields.Description, fields.UpdatedBy, fields.SourcingType, fields.LongDescription != nil, fields.LongDescription, ) if err != nil { return fmt.Errorf("updating item: %w", err) } return nil } // SetThumbnailKey updates the thumbnail_key on an item. func (r *ItemRepository) SetThumbnailKey(ctx context.Context, itemID string, key string) error { _, err := r.db.pool.Exec(ctx, `UPDATE items SET thumbnail_key = $1, updated_at = now() WHERE id = $2`, key, itemID, ) if err != nil { return fmt.Errorf("setting thumbnail key: %w", err) } return nil } // Delete permanently removes an item and all its revisions. func (r *ItemRepository) Delete(ctx context.Context, id string) error { _, err := r.db.pool.Exec(ctx, ` DELETE FROM items WHERE id = $1 `, id) if err != nil { return fmt.Errorf("deleting item: %w", err) } return nil } // Unarchive restores a soft-deleted item. func (r *ItemRepository) Unarchive(ctx context.Context, id string) error { _, err := r.db.pool.Exec(ctx, ` UPDATE items SET archived_at = NULL, updated_at = now() WHERE id = $1 `, id) return err }