package nodes import ( "database/sql" "errors" "fmt" "time" "verstak/internal/core/storage" "verstak/internal/core/util" ) var ( // ErrNotFound is returned when a node cannot be located. ErrNotFound = errors.New("node not found") // ErrDuplicateSlug means slug uniqueness is violated within the parent. ErrDuplicateSlug = errors.New("duplicate slug under the same parent") ) // Repository provides CRUD for the nodes table. type Repository struct { db *storage.DB } // NewRepository wraps an open storage DB. func NewRepository(db *storage.DB) *Repository { return &Repository{db: db} } // DB returns the underlying storage (useful for transactions). func (r *Repository) DB() *storage.DB { return r.db } // tx is a small helper for one-statement anonymous function transactions. func tx(db *storage.DB, fn func(*sql.Tx) error) error { t, err := db.Begin() if err != nil { return err } defer t.Rollback() if err := fn(t); err != nil { return err } return t.Commit() } // now returns an RFC3339 timestamp. func now() string { return time.Now().UTC().Format(time.RFC3339) } // Create inserts a root or child node. // parentID may be empty for root-level nodes. // For root nodes, section determines sidebar placement (may be empty = inbox). // section must be a valid section (clients, projects, etc.) or empty for inbox. func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) { if !IsValidType(typ) { return nil, fmt.Errorf("invalid node type: %s", typ) } if title == "" { return nil, errors.New("title is required") } if section != "" && !IsValidSection(section) { return nil, fmt.Errorf("invalid section: %s", section) } n := &Node{ ID: util.UUID7(), Type: typ, Title: title, Slug: Slugify(title), Section: section, SortOrder: 0, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), Revision: 1, } if parentID != "" { n.ParentID = &parentID } err := r.insertNode(n) if err != nil { return nil, err } // Bump parent's updated_at so it appears in today view. if parentID != "" { _ = r.touch(parentID) } return n, nil } // touch updates a node's updated_at without changing other fields. func (r *Repository) touch(id string) error { t := now() _, err := r.db.Exec( `UPDATE nodes SET updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, t, id) return err } func (r *Repository) insertNode(n *Node) error { var parent interface{} if n.ParentID != nil { parent = *n.ParentID } var sec interface{} if n.Section != "" { sec = n.Section } _, err := r.db.Exec( `INSERT INTO nodes (id,parent_id,type,title,slug,path,section,sort_order, created_at,updated_at,deleted_at,revision,device_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, n.ID, parent, n.Type, n.Title, n.Slug, n.Path, sec, n.SortOrder, n.CreatedAt.Format(time.RFC3339), n.UpdatedAt.Format(time.RFC3339), n.DeletedAt, n.Revision, n.DeviceID, ) return err } // Get returns a plain node (even if soft-deleted). func (r *Repository) Get(id string) (*Node, error) { row := r.db.QueryRow( `SELECT id,parent_id,type,title,slug,path,section,sort_order, created_at,updated_at,deleted_at,revision,device_id FROM nodes WHERE id = ?`, id) return scanNode(row) } // GetActive returns ErrNotFound if the node is soft-deleted or missing. func (r *Repository) GetActive(id string) (*Node, error) { n, err := r.Get(id) if err != nil { return nil, err } if n.IsDeleted() { return nil, ErrNotFound } return n, nil } // ListChildren returns direct children ordered by sort_order, then title. // IncludeDeleted lists soft-deleted children too. func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node, error) { q := `SELECT id,parent_id,type,title,slug,path,section,sort_order, created_at,updated_at,deleted_at,revision,device_id FROM nodes WHERE parent_id = ?` if !includeDeleted { q += " AND deleted_at IS NULL" } q += " ORDER BY sort_order, title" rows, err := r.db.Query(q, parentID) if err != nil { return nil, err } defer rows.Close() return scanNodes(rows) } // ListRoots returns nodes with no parent (top-level). // When section is set, only returns roots with that exact section // (or section IS NULL when section="inbox"). func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, error) { q := `SELECT id,parent_id,type,title,slug,path,section,sort_order, created_at,updated_at,deleted_at,revision,device_id FROM nodes WHERE parent_id IS NULL` if section == "inbox" { q += " AND section IS NULL" } else if section != "" { q += " AND section = ?" } if !includeDeleted { q += " AND deleted_at IS NULL" } q += " ORDER BY sort_order, title" var rows *sql.Rows var err error if section != "" && section != "inbox" { rows, err = r.db.Query(q, section) } else { rows, err = r.db.Query(q) } if err != nil { return nil, err } defer rows.Close() return scanNodes(rows) } // todayBoundaries returns RFC3339 start and end strings for the current day // in UTC, so string comparison against UTC-stored DB timestamps is correct. func todayBoundaries() (string, string) { now := time.Now() y, m, d := now.Date() start := time.Date(y, m, d, 0, 0, 0, 0, now.Location()) end := start.Add(24 * time.Hour) return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339) } // ListTodayNodes returns active root-level nodes created or updated today. // This is a dynamic view, not a section — it shows the day's activity. // Child nodes (notes, files, folders) are not listed directly; instead, // their parent is bumped via touch() on creation. func (r *Repository) ListTodayNodes() ([]Node, error) { start, end := todayBoundaries() q := `SELECT id,parent_id,type,title,slug,path,section,sort_order, created_at,updated_at,deleted_at,revision,device_id FROM nodes WHERE deleted_at IS NULL AND parent_id IS NULL AND ( (created_at >= ? AND created_at < ?) OR (updated_at >= ? AND updated_at < ?) ) ORDER BY updated_at DESC, created_at DESC` rows, err := r.db.Query(q, start, end, start, end) if err != nil { return nil, err } defer rows.Close() return scanNodes(rows) } // UpdateTitle changes title (slug is recomputed). func (r *Repository) UpdateTitle(id, title string) error { if title == "" { return errors.New("title required") } slug := Slugify(title) t := now() res, err := r.db.Exec( `UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, title, slug, t, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } // Move changes the parent and/or sort order of a node. // parentID="" means move to root. func (r *Repository) Move(id, parentID string, sortOrder int) error { var parent interface{} if parentID != "" { parent = parentID } t := now() res, err := r.db.Exec( `UPDATE nodes SET parent_id=?, sort_order=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, parent, sortOrder, t, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } // SoftDelete marks a node as deleted (does not touch its children). func (r *Repository) SoftDelete(id string) error { t := now() res, err := r.db.Exec( `UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, t, t, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } // MetaSet sets or overwrites a key-value pair for a node. func (r *Repository) MetaSet(nodeID, key, value string) error { _, err := r.db.Exec( `INSERT INTO node_meta (node_id,key,value) VALUES (?,?,?) ON CONFLICT(node_id,key) DO UPDATE SET value=excluded.value`, nodeID, key, value) return err } // MetaGet returns a single meta value, ok=false if missing. func (r *Repository) MetaGet(nodeID, key string) (string, bool, error) { var v string err := r.db.QueryRow( `SELECT value FROM node_meta WHERE node_id=? AND key=?`, nodeID, key).Scan(&v) if err == sql.ErrNoRows { return "", false, nil } if err != nil { return "", false, err } return v, true, nil } // MetaList returns all meta for a node. func (r *Repository) MetaList(nodeID string) ([]Meta, error) { rows, err := r.db.Query( `SELECT key,value FROM node_meta WHERE node_id=?`, nodeID) if err != nil { return nil, err } defer rows.Close() var out []Meta for rows.Next() { var m Meta if err := rows.Scan(&m.Key, &m.Value); err != nil { return nil, err } out = append(out, m) } return out, rows.Err() } // --- scanning helpers --- type scanner interface { Scan(dest ...interface{}) error } func scanNode(s scanner) (*Node, error) { var n Node var parentID, path, section, deletedAt, deviceID sql.NullString var createdStr, updatedStr string err := s.Scan( &n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &path, §ion, &n.SortOrder, &createdStr, &updatedStr, &deletedAt, &n.Revision, &deviceID, ) if err == sql.ErrNoRows { return nil, ErrNotFound } if err != nil { return nil, err } if parentID.Valid { n.ParentID = &parentID.String } if path.Valid { n.Path = &path.String } if section.Valid { n.Section = section.String } if deletedAt.Valid { t, _ := time.Parse(time.RFC3339, deletedAt.String) n.DeletedAt = &t } if deviceID.Valid { n.DeviceID = &deviceID.String } n.CreatedAt, _ = time.Parse(time.RFC3339, createdStr) n.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr) return &n, nil } func scanNodes(rows *sql.Rows) ([]Node, error) { var out []Node for rows.Next() { n, err := scanNode(rows) if err != nil { return nil, err } out = append(out, *n) } return out, rows.Err() }