Files
vibetree/internal/itemtypes/itemtypes.go
T
Owen f3a959334f Rename project from inventree to vibetree
- go.mod module path (and every internal/vibetree import) inventree -> vibetree
- cmd/inventree/ -> cmd/vibetree/, deploy/inventree.service -> vibetree.service
- Default site name (seed data + the siteName template func's fallback)
  is now "vibetree", shown in the navbar, <title>, and login page
- Session cookie renamed inventree_session -> vibetree_session
- Default DB path data/inventree.db -> data/vibetree.db, CLI/log text,
  Dockerfile, docker-compose.yml, README, .gitignore/.dockerignore all
  updated to match

Existing installs: this changes the session cookie name (users will be
signed out once) and the default DB filename (point -db at your existing
file, or rename it, to keep your data).
2026-08-17 17:14:52 -05:00

257 lines
7.6 KiB
Go

// Package itemtypes manages admin-defined asset types and their custom fields.
package itemtypes
import (
"context"
"database/sql"
"errors"
"regexp"
"strings"
"time"
"vibetree/internal/models"
)
var (
ErrNotFound = errors.New("item type not found")
ErrNameRequired = errors.New("item type name is required")
ErrInUse = errors.New("item type is assigned to one or more assets")
ErrFieldNotFound = errors.New("custom field not found")
ErrFieldName = errors.New("field name is required")
ErrFieldType = errors.New("invalid field type")
)
type Store struct{ db *sql.DB }
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
const timeLayout = "2006-01-02T15:04:05.000Z"
func parseTime(s string) time.Time {
t, _ := time.Parse(timeLayout, s)
return t
}
var slugRe = regexp.MustCompile(`[^a-z0-9_]+`)
// Slugify converts a field name into a snake_case key suitable as field_key.
func Slugify(name string) string {
s := strings.ToLower(strings.TrimSpace(name))
s = slugRe.ReplaceAllString(s, "_")
s = strings.Trim(s, "_")
if s == "" {
s = "field"
}
return s
}
func validFieldType(t models.FieldType) bool {
for _, ft := range models.AllFieldTypes {
if ft == t {
return true
}
}
return false
}
// List returns all item types, optionally including disabled ones, each with
// its custom field definitions loaded.
func (s *Store) List(ctx context.Context, includeDisabled bool) ([]models.ItemType, error) {
q := `SELECT id, name, description, icon, enabled, sort_order, is_unique, created_at, updated_at FROM item_types`
if !includeDisabled {
q += ` WHERE enabled = 1`
}
q += ` ORDER BY sort_order ASC, name COLLATE NOCASE ASC`
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.ItemType
for rows.Next() {
var it models.ItemType
var created, updated string
if err := rows.Scan(&it.ID, &it.Name, &it.Description, &it.Icon, &it.Enabled, &it.SortOrder, &it.IsUnique, &created, &updated); err != nil {
return nil, err
}
it.CreatedAt = parseTime(created)
it.UpdatedAt = parseTime(updated)
out = append(out, it)
}
if err := rows.Err(); err != nil {
return nil, err
}
for i := range out {
fields, err := s.Fields(ctx, out[i].ID)
if err != nil {
return nil, err
}
out[i].Fields = fields
}
return out, nil
}
func (s *Store) Get(ctx context.Context, id int64) (*models.ItemType, error) {
row := s.db.QueryRowContext(ctx, `SELECT id, name, description, icon, enabled, sort_order, is_unique, created_at, updated_at FROM item_types WHERE id = ?`, id)
var it models.ItemType
var created, updated string
err := row.Scan(&it.ID, &it.Name, &it.Description, &it.Icon, &it.Enabled, &it.SortOrder, &it.IsUnique, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
it.CreatedAt = parseTime(created)
it.UpdatedAt = parseTime(updated)
fields, err := s.Fields(ctx, it.ID)
if err != nil {
return nil, err
}
it.Fields = fields
return &it, nil
}
func (s *Store) Fields(ctx context.Context, itemTypeID int64) ([]models.CustomFieldDef, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, item_type_id, name, field_key, field_type, required, default_value, options, sort_order, created_at, updated_at
FROM custom_field_defs WHERE item_type_id = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC`, itemTypeID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.CustomFieldDef
for rows.Next() {
var f models.CustomFieldDef
var created, updated, options string
if err := rows.Scan(&f.ID, &f.ItemTypeID, &f.Name, &f.FieldKey, &f.FieldType, &f.Required, &f.DefaultValue, &options, &f.SortOrder, &created, &updated); err != nil {
return nil, err
}
if options != "" {
f.Options = strings.Split(options, "\n")
}
f.CreatedAt = parseTime(created)
f.UpdatedAt = parseTime(updated)
out = append(out, f)
}
return out, rows.Err()
}
func (s *Store) Create(ctx context.Context, it *models.ItemType) error {
if it.Name == "" {
return ErrNameRequired
}
if it.Icon == "" {
it.Icon = "bi-box-seam"
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO item_types (name, description, icon, enabled, sort_order, is_unique) VALUES (?, ?, ?, ?, ?, ?)`,
it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder, it.IsUnique)
if err != nil {
return err
}
id, err := res.LastInsertId()
if err != nil {
return err
}
it.ID = id
return nil
}
func (s *Store) Update(ctx context.Context, it *models.ItemType) error {
if it.Name == "" {
return ErrNameRequired
}
_, err := s.db.ExecContext(ctx,
`UPDATE item_types SET name = ?, description = ?, icon = ?, enabled = ?, sort_order = ?, is_unique = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
it.Name, it.Description, it.Icon, it.Enabled, it.SortOrder, it.IsUnique, it.ID)
if err != nil {
return err
}
if it.IsUnique {
// Item type just became (or remained) unique: bring every asset
// of this type back to quantity 1 so the two concepts can't drift
// out of sync.
_, err = s.db.ExecContext(ctx, `UPDATE assets SET quantity = 1 WHERE item_type_id = ? AND quantity != 1`, it.ID)
}
return err
}
func (s *Store) Delete(ctx context.Context, id int64) error {
var count int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets WHERE item_type_id = ?`, id).Scan(&count); err != nil {
return err
}
if count > 0 {
return ErrInUse
}
_, err := s.db.ExecContext(ctx, `DELETE FROM item_types WHERE id = ?`, id)
return err
}
func (s *Store) CreateField(ctx context.Context, f *models.CustomFieldDef) error {
if f.Name == "" {
return ErrFieldName
}
if !validFieldType(f.FieldType) {
return ErrFieldType
}
if f.FieldKey == "" {
f.FieldKey = Slugify(f.Name)
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO custom_field_defs (item_type_id, name, field_key, field_type, required, default_value, options, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
f.ItemTypeID, f.Name, f.FieldKey, f.FieldType, f.Required, f.DefaultValue, strings.Join(f.Options, "\n"), f.SortOrder)
if err != nil {
return err
}
id, err := res.LastInsertId()
if err != nil {
return err
}
f.ID = id
return nil
}
func (s *Store) UpdateField(ctx context.Context, f *models.CustomFieldDef) error {
if f.Name == "" {
return ErrFieldName
}
if !validFieldType(f.FieldType) {
return ErrFieldType
}
_, err := s.db.ExecContext(ctx,
`UPDATE custom_field_defs SET name = ?, field_type = ?, required = ?, default_value = ?, options = ?, sort_order = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id = ?`,
f.Name, f.FieldType, f.Required, f.DefaultValue, strings.Join(f.Options, "\n"), f.SortOrder, f.ID)
return err
}
func (s *Store) DeleteField(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM custom_field_defs WHERE id = ?`, id)
return err
}
func (s *Store) GetField(ctx context.Context, id int64) (*models.CustomFieldDef, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, item_type_id, name, field_key, field_type, required, default_value, options, sort_order, created_at, updated_at
FROM custom_field_defs WHERE id = ?`, id)
var f models.CustomFieldDef
var created, updated, options string
err := row.Scan(&f.ID, &f.ItemTypeID, &f.Name, &f.FieldKey, &f.FieldType, &f.Required, &f.DefaultValue, &options, &f.SortOrder, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrFieldNotFound
}
if err != nil {
return nil, err
}
if options != "" {
f.Options = strings.Split(options, "\n")
}
f.CreatedAt = parseTime(created)
f.UpdatedAt = parseTime(updated)
return &f, nil
}