330 lines
9.0 KiB
Go
330 lines
9.0 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
|
|
}
|
|
|
|
// 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
|
|
}
|