A small IT asset inventory system built around a nested folder tree, matching the project brief: Go + html/template + Bootstrap 5 + vanilla JS, SQLite storage (WAL, FKs, migrations), embedded templates/static assets, single compiled binary. - AJAX-driven inventory tree: lazy-loaded folder expansion with per-user persisted expand state, drag-and-drop move for folders/assets, safe delete-with-relocate-or-cascade confirmation, search with breadcrumbs. - Assets: CRUD, duplicate, move, auto-generated IT-000001-style IDs, CSV import (with optional folder auto-create) and export. - Admin-defined item types with custom fields (text/long_text/number/date/ boolean/dropdown/url/ip_address). - Configurable statuses, session-based auth (bcrypt, secure cookies), admin/user roles, user management. - CLI: serve, version, database migrate, database backup; /healthz; graceful shutdown; Dockerfile/docker-compose/systemd unit; Go test suite.
343 lines
9.7 KiB
Go
343 lines
9.7 KiB
Go
// Package folders implements the nested folder tree that organizes assets.
|
|
package folders
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"inventree/internal/models"
|
|
)
|
|
|
|
var (
|
|
ErrNotFound = errors.New("folder not found")
|
|
ErrInvalidParent = errors.New("cannot move a folder into itself or one of its own descendants")
|
|
ErrNameRequired = errors.New("folder name is required")
|
|
ErrNotEmpty = errors.New("folder still contains child folders or assets")
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
func scanFolder(row interface{ Scan(...any) error }) (*models.Folder, error) {
|
|
var f models.Folder
|
|
var parentID sql.NullInt64
|
|
var created, updated string
|
|
if err := row.Scan(&f.ID, &f.Name, &parentID, &f.Description, &f.Icon, &f.SortOrder, &created, &updated); err != nil {
|
|
return nil, err
|
|
}
|
|
if parentID.Valid {
|
|
f.ParentID = &parentID.Int64
|
|
}
|
|
f.CreatedAt = parseTime(created)
|
|
f.UpdatedAt = parseTime(updated)
|
|
return &f, nil
|
|
}
|
|
|
|
func (s *Store) Get(ctx context.Context, id int64) (*models.Folder, error) {
|
|
row := s.db.QueryRowContext(ctx, `SELECT id, name, parent_id, description, icon, sort_order, created_at, updated_at FROM folders WHERE id = ?`, id)
|
|
f, err := scanFolder(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// Children returns the direct child folders of parentID (nil for top level),
|
|
// ordered by sort_order then name, annotated with folder/asset counts.
|
|
func (s *Store) Children(ctx context.Context, parentID *int64) ([]models.Folder, error) {
|
|
query := `
|
|
SELECT f.id, f.name, f.parent_id, f.description, f.icon, f.sort_order, f.created_at, f.updated_at,
|
|
(SELECT COUNT(*) FROM folders c WHERE c.parent_id = f.id) AS folder_count,
|
|
(SELECT COUNT(*) FROM assets a WHERE a.folder_id = f.id) AS asset_count
|
|
FROM folders f
|
|
WHERE f.parent_id IS ?
|
|
ORDER BY f.sort_order ASC, f.name COLLATE NOCASE ASC`
|
|
rows, err := s.db.QueryContext(ctx, query, parentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []models.Folder
|
|
for rows.Next() {
|
|
var f models.Folder
|
|
var pid sql.NullInt64
|
|
var created, updated string
|
|
if err := rows.Scan(&f.ID, &f.Name, &pid, &f.Description, &f.Icon, &f.SortOrder, &created, &updated, &f.FolderCount, &f.AssetCount); err != nil {
|
|
return nil, err
|
|
}
|
|
if pid.Valid {
|
|
f.ParentID = &pid.Int64
|
|
}
|
|
f.CreatedAt = parseTime(created)
|
|
f.UpdatedAt = parseTime(updated)
|
|
out = append(out, f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// All returns every folder in the system (id, name, parent_id only) for
|
|
// building the full in-memory tree or search indexes.
|
|
func (s *Store) All(ctx context.Context) ([]models.Folder, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, name, parent_id, description, icon, sort_order, created_at, updated_at FROM folders ORDER BY sort_order ASC, name COLLATE NOCASE ASC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Folder
|
|
for rows.Next() {
|
|
f, err := scanFolder(rowsScanner{rows})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
type rowsScanner struct{ r *sql.Rows }
|
|
|
|
func (rs rowsScanner) Scan(dest ...any) error { return rs.r.Scan(dest...) }
|
|
|
|
func (s *Store) Create(ctx context.Context, f *models.Folder) error {
|
|
if f.Name == "" {
|
|
return ErrNameRequired
|
|
}
|
|
if f.ParentID != nil {
|
|
if _, err := s.Get(ctx, *f.ParentID); err != nil {
|
|
return fmt.Errorf("parent folder: %w", err)
|
|
}
|
|
}
|
|
res, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO folders (name, parent_id, description, icon, sort_order) VALUES (?, ?, ?, ?, ?)`,
|
|
f.Name, f.ParentID, f.Description, f.Icon, f.SortOrder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f.ID = id
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) Update(ctx context.Context, f *models.Folder) error {
|
|
if f.Name == "" {
|
|
return ErrNameRequired
|
|
}
|
|
_, err := s.db.ExecContext(ctx,
|
|
`UPDATE folders SET name = ?, description = ?, icon = ?, sort_order = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
|
f.Name, f.Description, f.Icon, f.SortOrder, f.ID)
|
|
return err
|
|
}
|
|
|
|
// IsDescendant reports whether candidateID is descendantOf ancestorID (or
|
|
// equal to it), by walking parent pointers.
|
|
func (s *Store) isSelfOrDescendant(ctx context.Context, folderID int64, targetParentID *int64) (bool, error) {
|
|
if targetParentID == nil {
|
|
return false, nil
|
|
}
|
|
cur := *targetParentID
|
|
for {
|
|
if cur == folderID {
|
|
return true, nil
|
|
}
|
|
var parentID sql.NullInt64
|
|
err := s.db.QueryRowContext(ctx, `SELECT parent_id FROM folders WHERE id = ?`, cur).Scan(&parentID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !parentID.Valid {
|
|
return false, nil
|
|
}
|
|
cur = parentID.Int64
|
|
}
|
|
}
|
|
|
|
// Move relocates a folder to a new parent (nil = top level), rejecting moves
|
|
// into itself or one of its own descendants.
|
|
func (s *Store) Move(ctx context.Context, id int64, newParentID *int64) error {
|
|
if newParentID != nil && *newParentID == id {
|
|
return ErrInvalidParent
|
|
}
|
|
bad, err := s.isSelfOrDescendant(ctx, id, newParentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if bad {
|
|
return ErrInvalidParent
|
|
}
|
|
if newParentID != nil {
|
|
if _, err := s.Get(ctx, *newParentID); err != nil {
|
|
return fmt.Errorf("parent folder: %w", err)
|
|
}
|
|
}
|
|
_, err = s.db.ExecContext(ctx,
|
|
`UPDATE folders SET parent_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
|
|
newParentID, id)
|
|
return err
|
|
}
|
|
|
|
// Counts returns the number of direct child folders and direct assets in a
|
|
// folder, used to decide whether deletion needs confirmation.
|
|
func (s *Store) Counts(ctx context.Context, id int64) (folderCount, assetCount int, err error) {
|
|
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM folders WHERE parent_id = ?`, id).Scan(&folderCount)
|
|
if err != nil {
|
|
return
|
|
}
|
|
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM assets WHERE folder_id = ?`, id).Scan(&assetCount)
|
|
return
|
|
}
|
|
|
|
// Delete removes an empty folder. It fails if the folder still has children
|
|
// or assets (enforced both here and by the FK constraint).
|
|
func (s *Store) Delete(ctx context.Context, id int64) error {
|
|
fc, ac, err := s.Counts(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if fc > 0 || ac > 0 {
|
|
return ErrNotEmpty
|
|
}
|
|
_, err = s.db.ExecContext(ctx, `DELETE FROM folders WHERE id = ?`, id)
|
|
return err
|
|
}
|
|
|
|
// DeleteAndRelocate moves all direct child folders and assets of id to
|
|
// destParentID/destFolderID (nil = top level / unfiled) and then deletes the
|
|
// now-empty folder id. Runs in a single transaction.
|
|
func (s *Store) DeleteAndRelocate(ctx context.Context, id int64, dest *int64) error {
|
|
if dest != nil {
|
|
bad, err := s.isSelfOrDescendant(ctx, id, dest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if bad || *dest == id {
|
|
return ErrInvalidParent
|
|
}
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx, `UPDATE folders SET parent_id = ? WHERE parent_id = ?`, dest, id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE assets SET folder_id = ? WHERE folder_id = ?`, dest, id); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM folders WHERE id = ?`, id); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// CascadeDelete permanently deletes a folder along with every descendant
|
|
// folder and asset inside it. Descendant folders are removed deepest-first
|
|
// so the ON DELETE RESTRICT foreign key is always satisfied.
|
|
func (s *Store) CascadeDelete(ctx context.Context, id int64) error {
|
|
all, err := s.All(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
childrenOf := map[int64][]int64{}
|
|
for _, f := range all {
|
|
if f.ParentID != nil {
|
|
childrenOf[*f.ParentID] = append(childrenOf[*f.ParentID], f.ID)
|
|
}
|
|
}
|
|
// Depth-first order, id last, so children are deleted before parents.
|
|
var order []int64
|
|
var walk func(int64)
|
|
walk = func(fid int64) {
|
|
for _, c := range childrenOf[fid] {
|
|
walk(c)
|
|
}
|
|
order = append(order, fid)
|
|
}
|
|
walk(id)
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
for _, fid := range order {
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM assets WHERE folder_id = ?`, fid); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM folders WHERE id = ?`, fid); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// Path returns breadcrumb items from the root down to (and including) id.
|
|
func (s *Store) Path(ctx context.Context, id int64) ([]models.BreadcrumbItem, error) {
|
|
var path []models.BreadcrumbItem
|
|
cur := &id
|
|
for cur != nil {
|
|
var name string
|
|
var parentID sql.NullInt64
|
|
err := s.db.QueryRowContext(ctx, `SELECT name, parent_id FROM folders WHERE id = ?`, *cur).Scan(&name, &parentID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
path = append([]models.BreadcrumbItem{{ID: *cur, Name: name}}, path...)
|
|
if parentID.Valid {
|
|
cur = &parentID.Int64
|
|
} else {
|
|
cur = nil
|
|
}
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// Search returns folders whose name matches the query (case-insensitive
|
|
// substring match).
|
|
func (s *Store) Search(ctx context.Context, query string) ([]models.Folder, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, name, parent_id, description, icon, sort_order, created_at, updated_at
|
|
FROM folders WHERE name LIKE '%' || ? || '%' COLLATE NOCASE ORDER BY name COLLATE NOCASE ASC LIMIT 50`, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []models.Folder
|
|
for rows.Next() {
|
|
f, err := scanFolder(rowsScanner{rows})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *f)
|
|
}
|
|
return out, rows.Err()
|
|
}
|