- 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
712 lines
21 KiB
Go
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
|
|
}
|