- 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).
257 lines
7.6 KiB
Go
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
|
|
}
|