// Package itemtypes manages admin-defined asset types and their custom fields. package itemtypes import ( "context" "database/sql" "errors" "regexp" "strings" "time" "inventree/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 }