// Package assets implements CRUD, search, and CSV import/export for IT // assets. package assets import ( "context" "database/sql" "errors" "fmt" "strconv" "strings" "time" "vibetree/internal/folders" "vibetree/internal/models" "vibetree/internal/settings" ) var ( ErrNotFound = errors.New("asset not found") ErrNameRequired = errors.New("asset name is required") ErrTagRequired = errors.New("asset ID is required") ErrTagTaken = errors.New("asset ID is already in use") ErrFieldRequired = errors.New("a required custom field is missing a value") ) type Store struct { db *sql.DB settings *settings.Store folders *folders.Store } func NewStore(db *sql.DB, st *settings.Store, fs *folders.Store) *Store { return &Store{db: db, settings: st, folders: fs} } const timeLayout = "2006-01-02T15:04:05.000Z" func parseTime(s string) time.Time { t, _ := time.Parse(timeLayout, s) return t } // GenerateAssetTag produces the next available asset ID such as IT-000042, // based on the configured prefix/padding, retrying past any manually // assigned collisions. func (s *Store) GenerateAssetTag(ctx context.Context) (string, error) { prefix, err := s.settings.Get(ctx, "asset_id_prefix", "IT-") if err != nil { return "", err } paddingStr, err := s.settings.Get(ctx, "asset_id_padding", "6") if err != nil { return "", err } padding, err := strconv.Atoi(paddingStr) if err != nil || padding <= 0 { padding = 6 } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return "", err } defer tx.Rollback() var seq int64 var seqStr string if err := tx.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = 'asset_id_seq'`).Scan(&seqStr); err != nil && !errors.Is(err, sql.ErrNoRows) { return "", err } if seqStr != "" { if n, err := strconv.ParseInt(seqStr, 10, 64); err == nil { seq = n } } var candidate string for { seq++ candidate = fmt.Sprintf("%s%0*d", prefix, padding, seq) var exists int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets WHERE asset_id = ?`, candidate).Scan(&exists); err != nil { return "", err } if exists == 0 { break } } if _, err := tx.ExecContext(ctx, `INSERT INTO settings (key, value, updated_at) VALUES ('asset_id_seq', ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, strconv.FormatInt(seq, 10)); err != nil { return "", err } if err := tx.Commit(); err != nil { return "", err } return candidate, nil } func (s *Store) tagExists(ctx context.Context, tag string, excludeID int64) (bool, error) { var count int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets WHERE asset_id = ? AND id != ?`, tag, excludeID).Scan(&count) return count > 0, err } // resolveQuantity clamps the requested quantity against the asset's item // type: unique types are always exactly 1; non-unique types default to 1 // when the request is less than 1 (0 or unset), since AdjustQuantity is the // intended path for deliberately going to 0 ("out of stock"). func (s *Store) resolveQuantity(ctx context.Context, itemTypeID int64, requested int) (int, error) { var isUnique bool if err := s.db.QueryRowContext(ctx, `SELECT is_unique FROM item_types WHERE id = ?`, itemTypeID).Scan(&isUnique); err != nil { return 0, err } if isUnique { return 1, nil } if requested < 1 { return 1, nil } return requested, nil } // Create inserts a new asset. If a.AssetTag is empty, one is generated. // fieldValues maps custom_field_defs.id to the submitted value. func (s *Store) Create(ctx context.Context, a *models.Asset, fieldValues map[int64]string) error { if a.Name == "" { return ErrNameRequired } if a.AssetTag == "" { tag, err := s.GenerateAssetTag(ctx) if err != nil { return err } a.AssetTag = tag } else if taken, err := s.tagExists(ctx, a.AssetTag, 0); err != nil { return err } else if taken { return ErrTagTaken } quantity, err := s.resolveQuantity(ctx, a.ItemTypeID, a.Quantity) if err != nil { return err } a.Quantity = quantity tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() res, err := tx.ExecContext(ctx, `INSERT INTO assets (asset_id, name, item_type_id, folder_id, status_id, assigned_user_id, notes, quantity) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes, a.Quantity) if err != nil { return err } id, err := res.LastInsertId() if err != nil { return err } a.ID = id if err := saveFieldValues(ctx, tx, id, fieldValues); err != nil { return err } return tx.Commit() } func saveFieldValues(ctx context.Context, tx *sql.Tx, assetID int64, values map[int64]string) error { for defID, val := range values { if _, err := tx.ExecContext(ctx, `INSERT INTO custom_field_values (asset_id, field_def_id, value) VALUES (?, ?, ?) ON CONFLICT(asset_id, field_def_id) DO UPDATE SET value = excluded.value`, assetID, defID, val); err != nil { return err } } return nil } func (s *Store) Update(ctx context.Context, a *models.Asset, fieldValues map[int64]string) error { if a.Name == "" { return ErrNameRequired } if a.AssetTag == "" { return ErrTagRequired } if taken, err := s.tagExists(ctx, a.AssetTag, a.ID); err != nil { return err } else if taken { return ErrTagTaken } quantity, err := s.resolveQuantity(ctx, a.ItemTypeID, a.Quantity) if err != nil { return err } a.Quantity = quantity tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() _, err = tx.ExecContext(ctx, `UPDATE assets SET asset_id = ?, name = ?, item_type_id = ?, folder_id = ?, status_id = ?, assigned_user_id = ?, notes = ?, quantity = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, a.AssetTag, a.Name, a.ItemTypeID, a.FolderID, a.StatusID, a.AssignedUserID, a.Notes, a.Quantity, a.ID) if err != nil { return err } if _, err := tx.ExecContext(ctx, `DELETE FROM custom_field_values WHERE asset_id = ?`, a.ID); err != nil { return err } if err := saveFieldValues(ctx, tx, a.ID, fieldValues); err != nil { return err } return tx.Commit() } func (s *Store) Delete(ctx context.Context, id int64) error { _, err := s.db.ExecContext(ctx, `DELETE FROM assets WHERE id = ?`, id) return err } func (s *Store) Move(ctx context.Context, id int64, folderID *int64) error { _, err := s.db.ExecContext(ctx, `UPDATE assets SET folder_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, folderID, id) return err } // AssignUser checks an asset out to a user, or checks it in (unassigns) // when userID is nil. It touches only the assignment, nothing else. func (s *Store) AssignUser(ctx context.Context, id int64, userID *int64) error { _, err := s.db.ExecContext(ctx, `UPDATE assets SET assigned_user_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, userID, id) return err } // SetStatus changes only an asset's status, independent of the full edit // form - used by the bulk "Edit Status" multi-select action. func (s *Store) SetStatus(ctx context.Context, id, statusID int64) error { _, err := s.db.ExecContext(ctx, `UPDATE assets SET status_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, statusID, id) return err } var ErrQuantityNotTracked = errors.New("quantity is not tracked for unique item types") // AdjustQuantity increments or decrements an asset's quantity by delta, // floored at 0. It fails for assets whose item type is unique (quantity // stays fixed at 1 for those; use duplicate/create for more distinct // units instead). func (s *Store) AdjustQuantity(ctx context.Context, id int64, delta int) (*models.Asset, error) { a, err := s.Get(ctx, id) if err != nil { return nil, err } if a.ItemTypeIsUnique { return nil, ErrQuantityNotTracked } newQty := a.Quantity + delta if newQty < 0 { newQty = 0 } if _, err := s.db.ExecContext(ctx, `UPDATE assets SET quantity = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, newQty, id); err != nil { return nil, err } a.Quantity = newQty return a, nil } // Duplicate copies an asset's fields and custom field values into a new // asset with a freshly generated asset tag. func (s *Store) Duplicate(ctx context.Context, id int64) (*models.Asset, error) { orig, err := s.Get(ctx, id) if err != nil { return nil, err } tag, err := s.GenerateAssetTag(ctx) if err != nil { return nil, err } copyAsset := &models.Asset{ AssetTag: tag, Name: orig.Name + " (Copy)", ItemTypeID: orig.ItemTypeID, FolderID: orig.FolderID, StatusID: orig.StatusID, AssignedUserID: orig.AssignedUserID, Notes: orig.Notes, Quantity: orig.Quantity, } values := map[int64]string{} for _, fv := range orig.CustomFields { if fv.Value != "" { values[fv.Def.ID] = fv.Value } } if err := s.Create(ctx, copyAsset, values); err != nil { return nil, err } return copyAsset, nil } const assetSelect = ` SELECT a.id, a.asset_id, a.name, a.item_type_id, a.folder_id, a.status_id, a.assigned_user_id, a.notes, a.quantity, a.created_at, a.updated_at, it.name, it.icon, it.is_unique, st.name, st.color, COALESCE(u.username, '') FROM assets a JOIN item_types it ON it.id = a.item_type_id JOIN statuses st ON st.id = a.status_id LEFT JOIN users u ON u.id = a.assigned_user_id` func scanAsset(row interface{ Scan(...any) error }) (*models.Asset, error) { var a models.Asset var folderID, assignedUserID sql.NullInt64 var created, updated string if err := row.Scan(&a.ID, &a.AssetTag, &a.Name, &a.ItemTypeID, &folderID, &a.StatusID, &assignedUserID, &a.Notes, &a.Quantity, &created, &updated, &a.ItemTypeName, &a.ItemTypeIcon, &a.ItemTypeIsUnique, &a.StatusName, &a.StatusColor, &a.AssignedUsername); err != nil { return nil, err } if folderID.Valid { a.FolderID = &folderID.Int64 } if assignedUserID.Valid { a.AssignedUserID = &assignedUserID.Int64 } a.CreatedAt = parseTime(created) a.UpdatedAt = parseTime(updated) return &a, nil } // Get loads an asset with its custom field values (including empty values // for fields with no data yet) and folder breadcrumb path. func (s *Store) Get(ctx context.Context, id int64) (*models.Asset, error) { row := s.db.QueryRowContext(ctx, assetSelect+` WHERE a.id = ?`, id) a, err := scanAsset(row) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } if err := s.loadExtras(ctx, a); err != nil { return nil, err } return a, nil } func (s *Store) GetByTag(ctx context.Context, tag string) (*models.Asset, error) { row := s.db.QueryRowContext(ctx, assetSelect+` WHERE a.asset_id = ?`, tag) a, err := scanAsset(row) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } if err := s.loadExtras(ctx, a); err != nil { return nil, err } return a, nil } func (s *Store) loadExtras(ctx context.Context, a *models.Asset) error { fields, err := s.fieldValues(ctx, a.ID, a.ItemTypeID) if err != nil { return err } a.CustomFields = fields if a.FolderID != nil { path, err := s.folders.Path(ctx, *a.FolderID) if err != nil { return err } a.FolderPath = path } return nil } func (s *Store) fieldValues(ctx context.Context, assetID, itemTypeID int64) ([]models.AssetFieldValue, error) { rows, err := s.db.QueryContext(ctx, ` SELECT d.id, d.item_type_id, d.name, d.field_key, d.field_type, d.required, d.default_value, d.options, d.sort_order, COALESCE(v.value, '') FROM custom_field_defs d LEFT JOIN custom_field_values v ON v.field_def_id = d.id AND v.asset_id = ? WHERE d.item_type_id = ? ORDER BY d.sort_order ASC, d.name COLLATE NOCASE ASC`, assetID, itemTypeID) if err != nil { return nil, err } defer rows.Close() var out []models.AssetFieldValue for rows.Next() { var fv models.AssetFieldValue var options string if err := rows.Scan(&fv.Def.ID, &fv.Def.ItemTypeID, &fv.Def.Name, &fv.Def.FieldKey, &fv.Def.FieldType, &fv.Def.Required, &fv.Def.DefaultValue, &options, &fv.Def.SortOrder, &fv.Value); err != nil { return nil, err } if options != "" { fv.Def.Options = strings.Split(options, "\n") } out = append(out, fv) } return out, rows.Err() } // ListFilter narrows a List query. A nil pointer means "don't filter on // this field"; FolderID pointing at a zero value with FolderSet=true means // "top-level / unfiled only". type ListFilter struct { FolderID *int64 FolderSet bool IncludeSub bool // if true and FolderID set, include assets in descendant folders too ItemTypeID int64 StatusID int64 AssignedUserID int64 Query string Page int PageSize int } // List returns assets matching the filter plus the total matching row count // (for pagination), ordered by name. func (s *Store) List(ctx context.Context, f ListFilter) ([]models.Asset, int, error) { var where []string var args []any if f.FolderSet { if f.IncludeSub && f.FolderID != nil { ids, err := s.descendantFolderIDs(ctx, *f.FolderID) if err != nil { return nil, 0, err } ids = append(ids, *f.FolderID) placeholders := make([]string, len(ids)) for i, id := range ids { placeholders[i] = "?" args = append(args, id) } where = append(where, "a.folder_id IN ("+strings.Join(placeholders, ",")+")") } else if f.FolderID == nil { where = append(where, "a.folder_id IS NULL") } else { where = append(where, "a.folder_id = ?") args = append(args, *f.FolderID) } } if f.ItemTypeID != 0 { where = append(where, "a.item_type_id = ?") args = append(args, f.ItemTypeID) } if f.StatusID != 0 { where = append(where, "a.status_id = ?") args = append(args, f.StatusID) } if f.AssignedUserID != 0 { where = append(where, "a.assigned_user_id = ?") args = append(args, f.AssignedUserID) } if f.Query != "" { where = append(where, "(a.name LIKE '%'||?||'%' COLLATE NOCASE OR a.asset_id LIKE '%'||?||'%' COLLATE NOCASE)") args = append(args, f.Query, f.Query) } whereClause := "" if len(where) > 0 { whereClause = "WHERE " + strings.Join(where, " AND ") } var total int countQuery := `SELECT COUNT(*) FROM assets a ` + whereClause if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil { return nil, 0, err } page, pageSize := f.Page, f.PageSize if page < 1 { page = 1 } if pageSize < 1 || pageSize > 500 { pageSize = 50 } offset := (page - 1) * pageSize query := assetSelect + " " + whereClause + " ORDER BY a.name COLLATE NOCASE ASC LIMIT ? OFFSET ?" args = append(args, pageSize, offset) rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, 0, err } defer rows.Close() var out []models.Asset for rows.Next() { a, err := scanAsset(rows) if err != nil { return nil, 0, err } out = append(out, *a) } return out, total, rows.Err() } func (s *Store) descendantFolderIDs(ctx context.Context, rootID int64) ([]int64, error) { all, err := s.folders.All(ctx) if err != nil { return nil, err } childrenOf := map[int64][]int64{} for _, f := range all { if f.ParentID != nil { childrenOf[*f.ParentID] = append(childrenOf[*f.ParentID], f.ID) } } var out []int64 var walk func(int64) walk = func(id int64) { for _, c := range childrenOf[id] { out = append(out, c) walk(c) } } walk(rootID) return out, nil }