verstak/internal/core/nodes/repository.go

389 lines
9.6 KiB
Go

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, &section,
&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()
}