package adminstore import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "regexp" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) // randomHex returns n random lowercase hex characters for fallback ltree slugs. func randomHex(n int) string { b := make([]byte, (n+1)/2) if _, err := rand.Read(b); err != nil { return "x" } return hex.EncodeToString(b)[:n] } // Category-management errors, mapped to client statuses by the handler. var ( // ErrDuplicatePath is returned when a category path already exists. ErrDuplicatePath = errors.New("duplicate category path") // ErrCategoryHasChildren blocks deleting a node that still has children. ErrCategoryHasChildren = errors.New("category has children") // ErrCategoryInUse blocks deleting a node still referenced by products. ErrCategoryInUse = errors.New("category in use") // ErrInvalidParent is returned for a missing parent or an illegal move // (onto itself or one of its own descendants). ErrInvalidParent = errors.New("invalid parent category") ) // CategoryInput is the payload accepted when creating or editing a category. // Slug is the ltree label (ASCII); when empty it is derived from NameEN, then // from a random suffix, since ltree labels cannot contain CJK or spaces. type CategoryInput struct { NameZH string `json:"name_zh"` NameEN *string `json:"name_en"` Slug *string `json:"slug"` ParentID *string `json:"parent_id"` GPCBrickCode *string `json:"gpc_brick_code"` } var slugInvalid = regexp.MustCompile(`[^a-z0-9_]+`) // slugify converts a string into a valid ltree label ([a-z0-9_]). func slugify(s string) string { s = strings.ToLower(strings.TrimSpace(s)) s = slugInvalid.ReplaceAllString(s, "_") s = strings.Trim(s, "_") for strings.Contains(s, "__") { s = strings.ReplaceAll(s, "__", "_") } return s } // resolveSlug picks an ltree label from the explicit slug, then NameEN, then a // random fallback so a Chinese-only category still gets a valid path label. func resolveSlug(in CategoryInput) string { if in.Slug != nil { if s := slugify(*in.Slug); s != "" { return s } } if in.NameEN != nil { if s := slugify(*in.NameEN); s != "" { return s } } return "cat_" + randomHex(6) } func trimPtr(p *string) *string { if p == nil { return nil } t := strings.TrimSpace(*p) if t == "" { return nil } return &t } // CreateCategory inserts a new category node. With no parent it becomes a root // (level 0); otherwise its path is parentPath.slug and level is parentLevel+1. func (s *Store) CreateCategory(ctx context.Context, actor string, in CategoryInput) (*Category, error) { name := strings.TrimSpace(in.NameZH) if name == "" { return nil, errors.New("name_zh required") } parentPath := "" parentLevel := -1 kind := DefaultKind var parentID *string if pid := trimPtr(in.ParentID); pid != nil { var path string var level int err := s.pool.QueryRow(ctx, "SELECT path::text, level, archive_kind FROM category WHERE id = $1", *pid).Scan(&path, &level, &kind) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrInvalidParent } if err != nil { return nil, err } parentPath, parentLevel, parentID = path, level, pid } slug := resolveSlug(in) path := slug if parentPath != "" { path = parentPath + "." + slug } level := parentLevel + 1 var c Category err := s.pool.QueryRow(ctx, ` INSERT INTO category (name_zh, name_en, parent_id, path, gpc_brick_code, level, archive_kind) VALUES ($1, $2, $3, $4::ltree, $5, $6, $7) RETURNING id, name_zh, name_en, path::text, level, parent_id::text, gpc_brick_code, archive_kind, 0`, name, trimPtr(in.NameEN), parentID, path, trimPtr(in.GPCBrickCode), level, kind). Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level, &c.ParentID, &c.GPCBrickCode, &c.ArchiveKind, &c.ProductCount) if isUniqueViolation(err) { return nil, ErrDuplicatePath } if err != nil { return nil, err } _ = s.writeAudit(ctx, actor, "create", "category", &c.ID, []string{"name_zh", "path"}, nil, c) return &c, nil } // UpdateCategory renames a node and/or moves it under a new parent. Moving // rewrites the path of the node and every descendant via ltree, keeping level // in sync. Moving a node onto itself or a descendant is rejected. func (s *Store) UpdateCategory(ctx context.Context, id, actor string, in CategoryInput) (*Category, error) { name := strings.TrimSpace(in.NameZH) if name == "" { return nil, errors.New("name_zh required") } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) var oldPath string var oldLevel int var oldParent *string err = tx.QueryRow(ctx, "SELECT path::text, level, parent_id::text FROM category WHERE id = $1", id). Scan(&oldPath, &oldLevel, &oldParent) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } if _, err := tx.Exec(ctx, "UPDATE category SET name_zh = $1, name_en = $2, gpc_brick_code = $3 WHERE id = $4", name, trimPtr(in.NameEN), trimPtr(in.GPCBrickCode), id); err != nil { return nil, err } newParent := trimPtr(in.ParentID) if !strEq(newParent, oldParent) { if err := s.moveCategoryTx(ctx, tx, id, oldPath, newParent); err != nil { return nil, err } } if err := tx.Commit(ctx); err != nil { return nil, err } out, err := s.getCategory(ctx, id) if err != nil { return nil, err } _ = s.writeAudit(ctx, actor, "update", "category", &id, []string{"name_zh", "name_en", "gpc_brick_code", "parent_id"}, nil, out) return out, nil } // moveCategoryTx re-parents a subtree. The node's slug (last path label) is // preserved; only its ancestor prefix and level change. func (s *Store) moveCategoryTx(ctx context.Context, tx pgx.Tx, id, oldPath string, newParent *string) error { slug := oldPath if i := strings.LastIndex(oldPath, "."); i >= 0 { slug = oldPath[i+1:] } newBase := slug if newParent != nil { var parentPath string err := tx.QueryRow(ctx, "SELECT path::text FROM category WHERE id = $1", *newParent).Scan(&parentPath) if errors.Is(err, pgx.ErrNoRows) { return ErrInvalidParent } if err != nil { return err } // Disallow moving a node under itself or one of its descendants. if parentPath == oldPath || strings.HasPrefix(parentPath, oldPath+".") { return ErrInvalidParent } newBase = parentPath + "." + slug } // Rewrite the node and all descendants in one statement; level tracks depth. _, err := tx.Exec(ctx, ` UPDATE category SET path = ($1::ltree || subpath(path, nlevel($2::ltree) - 1)), level = nlevel($1::ltree) + (nlevel(path) - nlevel($2::ltree)) - 1 WHERE path = $2::ltree OR path <@ $2::ltree`, newBase, oldPath) if isUniqueViolation(err) { return ErrDuplicatePath } if err != nil { return err } _, err = tx.Exec(ctx, "UPDATE category SET parent_id = $1 WHERE id = $2", newParent, id) return err } // DeleteCategory removes a leaf node not referenced by any product. Nodes with // children or in-use nodes are rejected with a specific error. func (s *Store) DeleteCategory(ctx context.Context, id, actor string) error { before, err := s.getCategory(ctx, id) if err != nil { return err } var children int if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM category WHERE parent_id = $1", id).Scan(&children); err != nil { return err } if children > 0 { return ErrCategoryHasChildren } var products int if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM product WHERE category_id = $1", id).Scan(&products); err != nil { return err } if products > 0 { return fmt.Errorf("%w: %d products", ErrCategoryInUse, products) } ct, err := s.pool.Exec(ctx, "DELETE FROM category WHERE id = $1", id) if err != nil { // A concurrent product assignment can still trip the FK. if isForeignKeyViolation(err) { return ErrCategoryInUse } return err } if ct.RowsAffected() == 0 { return ErrNotFound } _ = s.writeAudit(ctx, actor, "delete", "category", &id, []string{"path"}, before, nil) return nil } func (s *Store) getCategory(ctx context.Context, id string) (*Category, error) { var c Category err := s.pool.QueryRow(ctx, ` SELECT c.id, c.name_zh, c.name_en, c.path::text, c.level, c.parent_id::text, c.gpc_brick_code, c.archive_kind, (SELECT count(*) FROM product p WHERE p.category_id = c.id) FROM category c WHERE c.id = $1`, id). Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level, &c.ParentID, &c.GPCBrickCode, &c.ArchiveKind, &c.ProductCount) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } return &c, nil } func isUniqueViolation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23505" } func isForeignKeyViolation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23503" }