Files
silo/internal/db/items.go
forbes-0023 b3c748ef10 refactor: move sourcing_link and standard_cost from item columns to revision properties
- Add migration 013 to copy sourcing_link/standard_cost values into
  current revision properties JSONB and drop the columns from items table
- Remove SourcingLink/StandardCost from Go Item struct and all DB queries
  (items.go, audit_queries.go, projects.go)
- Remove from API request/response structs and handlers
- Update CSV/ODS/BOM export/import to read these from revision properties
- Update audit handlers to score as regular property fields
- Remove from frontend Item type and hardcoded form fields
- MainTab now reads sourcing_link/standard_cost from item.properties
- CreateItemPane/EditItemPane no longer have dedicated fields for these;
  they will be rendered as schema-driven property fields
2026-02-11 09:50:31 -06:00

712 lines
21 KiB
Go

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
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)
}
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) {
// 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)
}
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.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)
}
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, 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.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{}
}
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, `
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
}