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 } // 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 ThumbnailKey *string CreatedAt time.Time CreatedBy *string Comment *string } // 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 err := tx.QueryRow(ctx, ` INSERT INTO items (part_number, schema_id, item_type, description) VALUES ($1, $2, $3, $4) RETURNING id, created_at, updated_at, current_revision `, item.PartNumber, item.SchemaID, item.ItemType, item.Description).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) VALUES ($1, 1, $2) `, item.ID, propsJSON) 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 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, ) 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 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, ) 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) { 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 ` args := []any{} argNum := 1 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) args = append(args, "%"+opts.Search+"%") argNum++ } 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, ) if err != nil { return nil, fmt.Errorf("scanning item: %w", err) } items = append(items, item) } return items, nil } // ListProjects returns distinct project codes from all items. 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 `) 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) } 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 ) SELECT $1, current_revision + 1, $2, $3, $4, $5, $6, $7, $8, $9 FROM items WHERE id = $1 RETURNING id, revision_number, created_at `, rev.ItemID, propsJSON, rev.FileKey, rev.FileVersion, rev.FileChecksum, rev.FileSize, 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) { 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 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 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 } // 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 } // Update modifies an item's part number, type, and description. // The UUID remains stable. func (r *ItemRepository) Update(ctx context.Context, id string, partNumber string, itemType string, description string) error { _, err := r.db.pool.Exec(ctx, ` UPDATE items SET part_number = $2, item_type = $3, description = $4, updated_at = now() WHERE id = $1 AND archived_at IS NULL `, id, partNumber, itemType, description) if err != nil { return fmt.Errorf("updating item: %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 }