From a36700076e291f5e04c5cbf9f406b7904cd59fc2 Mon Sep 17 00:00:00 2001 From: sulaimaannaasif6866 Date: Sun, 21 Jun 2026 01:31:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=93=81=E7=89=8C=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20+=20=E6=96=B0=E5=BB=BA=E5=95=86=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 品牌管理:列出品牌及引用商品数,支持改名、合并重复品牌(把源品牌的商品并入目标后删除源)、删除未被引用的品牌。新建商品:商品列表新增「新建商品」入口,填写名称/条码/品牌/品类后创建并进入详情页继续补全。 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- admin-frontend/src/App.tsx | 17 +- admin-frontend/src/api.ts | 22 ++ admin-frontend/src/components/BrandsPage.tsx | 305 ++++++++++++++++++ admin-frontend/src/components/ProductList.tsx | 151 ++++++++- admin-frontend/src/types.ts | 1 + api/internal/adminhandler/handler.go | 118 +++++++ api/internal/adminstore/brand.go | 154 +++++++++ api/internal/adminstore/brand_test.go | 78 +++++ api/internal/adminstore/write.go | 91 +++++- 9 files changed, 926 insertions(+), 11 deletions(-) create mode 100644 admin-frontend/src/components/BrandsPage.tsx create mode 100644 api/internal/adminstore/brand.go create mode 100644 api/internal/adminstore/brand_test.go diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx index 3aeae20..80db3cb 100644 --- a/admin-frontend/src/App.tsx +++ b/admin-frontend/src/App.tsx @@ -6,9 +6,10 @@ import ProductDetail from "./components/ProductDetail"; import SubmissionsPage from "./components/SubmissionsPage"; import ApiKeysPage from "./components/ApiKeysPage"; import CategoriesPage from "./components/CategoriesPage"; -import { FolderTree, Inbox, KeyRound, LogOut, Package } from "lucide-react"; +import BrandsPage from "./components/BrandsPage"; +import { FolderTree, Inbox, KeyRound, LogOut, Package, Tag } from "lucide-react"; -type Tab = "products" | "submissions" | "categories" | "keys"; +type Tab = "products" | "submissions" | "categories" | "brands" | "keys"; type View = { name: "list" } | { name: "detail"; id: string }; export default function App() { @@ -114,6 +115,16 @@ export default function App() { > 分类管理 + + + + {error && ( +
{error}
+ )} + +
+ + setQuery(e.target.value)} + placeholder="筛选品牌名" + className="w-full rounded border border-gray-300 py-2 pl-8 pr-3 text-sm focus:border-emerald-500 focus:outline-none" + /> +
+ + {creating && ( +
+

新建品牌

+
+ + + +
+
+ )} + + {merge && ( +
+

+ 合并品牌 +

+

+ 把「{merge.source.name}」({merge.source.product_count} 个商品)合并到下面选择的品牌,源品牌将被删除。 +

+
+ + + +
+
+ )} + +
+ + + + + + + + + + {filtered.length === 0 ? ( + + + + ) : ( + filtered.map((b) => ( + + + + + + )) + )} + +
品牌商品数
+ {rows.length === 0 ? "暂无品牌" : "无匹配品牌"} +
+ {edit && edit.id === b.id ? ( + setEdit({ ...edit, name: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") saveEdit(); + if (e.key === "Escape") setEdit(null); + }} + /> + ) : ( + {b.name} + )} + {b.product_count} + {edit && edit.id === b.id ? ( + <> + + + + ) : ( + <> + + + + + )} +
+
+ + ); +} diff --git a/admin-frontend/src/components/ProductList.tsx b/admin-frontend/src/components/ProductList.tsx index 409a30e..bfe43b0 100644 --- a/admin-frontend/src/components/ProductList.tsx +++ b/admin-frontend/src/components/ProductList.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; -import { api } from "../api"; -import { FIELD_LABELS, ProductRow } from "../types"; -import { Search, AlertCircle } from "lucide-react"; +import { api, ApiError } from "../api"; +import { Brand, Category, FIELD_LABELS, ProductRow } from "../types"; +import { Search, AlertCircle, Plus } from "lucide-react"; const STATUS_LABEL: Record = { active: "在用", @@ -37,6 +37,7 @@ export default function ProductList({ const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [creating, setCreating] = useState(false); useEffect(() => { setLoading(true); @@ -79,9 +80,26 @@ export default function ProductList({ + + {creating && ( + setCreating(false)} + onCreated={(id) => { + setCreating(false); + onOpen(id); + }} + /> + )} + {error && (
{error} @@ -176,3 +194,130 @@ export default function ProductList({
); } + +function CreateProductModal({ + onClose, + onCreated, +}: { + onClose: () => void; + onCreated: (id: string) => void; +}) { + const [name, setName] = useState(""); + const [gtin, setGtin] = useState(""); + const [brand, setBrand] = useState(""); + const [categoryId, setCategoryId] = useState(""); + const [brands, setBrands] = useState([]); + const [categories, setCategories] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + api.listBrands().then((r) => setBrands(r.items)).catch(() => {}); + api.listCategories().then((r) => setCategories(r.items)).catch(() => {}); + }, []); + + async function submit() { + if (!name.trim()) { + setError("名称不能为空"); + return; + } + setBusy(true); + setError(""); + try { + const created = await api.createProduct({ + name: name.trim(), + gtin: gtin.trim() || null, + brand_name: brand.trim() || null, + category_id: categoryId || null, + status: "active", + }); + onCreated(created.id); + } catch (e) { + setError(e instanceof ApiError ? e.message : "新建失败"); + } finally { + setBusy(false); + } + } + + return ( +
+
+

新建商品

+ {error && ( +
+ {error} +
+ )} +
+ + + + +
+

+ 创建后将进入详情页,可继续补全营养、图片、多条码等信息。 +

+
+ + +
+
+
+ ); +} diff --git a/admin-frontend/src/types.ts b/admin-frontend/src/types.ts index 6ffa6bd..c74ef1a 100644 --- a/admin-frontend/src/types.ts +++ b/admin-frontend/src/types.ts @@ -66,6 +66,7 @@ export interface ProductDetail { export interface Brand { id: string; name: string; + product_count: number; } export interface Category { diff --git a/api/internal/adminhandler/handler.go b/api/internal/adminhandler/handler.go index 547eba6..df091a0 100644 --- a/api/internal/adminhandler/handler.go +++ b/api/internal/adminhandler/handler.go @@ -65,6 +65,7 @@ func (h *Handler) Router() http.Handler { r.Use(h.authn.Middleware) r.Get("/api/me", h.Me) r.Get("/api/products", h.ListProducts) + r.Post("/api/products", h.CreateProduct) r.Get("/api/products/{id}", h.GetProduct) r.Put("/api/products/{id}", h.UpdateProduct) r.Get("/api/products/{id}/audit", h.ListAudit) @@ -76,6 +77,10 @@ func (h *Handler) Router() http.Handler { r.Delete("/api/products/{id}/barcodes/{barcodeID}", h.DeleteBarcode) r.Post("/api/products/{id}/barcodes/{barcodeID}/primary", h.SetPrimaryBarcode) r.Get("/api/brands", h.ListBrands) + r.Post("/api/brands", h.CreateBrand) + r.Put("/api/brands/{id}", h.UpdateBrand) + r.Post("/api/brands/{id}/merge", h.MergeBrands) + r.Delete("/api/brands/{id}", h.DeleteBrand) r.Get("/api/categories", h.ListCategories) r.Post("/api/categories", h.CreateCategory) r.Put("/api/categories/{id}", h.UpdateCategory) @@ -175,6 +180,29 @@ func (h *Handler) GetProduct(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, d) } +// CreateProduct adds a new product with core fields; the rest is filled in via +// the detail editor. +func (h *Handler) CreateProduct(w http.ResponseWriter, r *http.Request) { + var in adminstore.ProductInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + if strings.TrimSpace(in.Name) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "名称不能为空") + return + } + d, err := h.store.CreateProduct(r.Context(), auth.UserFrom(r.Context()), in) + if errors.Is(err, adminstore.ErrDuplicateGTIN) { + writeError(w, http.StatusConflict, "duplicate_gtin", "该条码(GTIN)已被其它商品使用") + return + } + if h.handleErr(w, err) { + return + } + writeJSON(w, http.StatusCreated, d) +} + // UpdateProduct applies an edit. func (h *Handler) UpdateProduct(w http.ResponseWriter, r *http.Request) { var in adminstore.ProductInput @@ -399,6 +427,96 @@ func (h *Handler) handleCategoryErr(w http.ResponseWriter, err error) bool { return h.handleErr(w, err) } +type brandInput struct { + Name string `json:"name"` +} + +type brandMergeInput struct { + TargetID string `json:"target_id"` +} + +// CreateBrand adds a brand. +func (h *Handler) CreateBrand(w http.ResponseWriter, r *http.Request) { + var in brandInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + if strings.TrimSpace(in.Name) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "品牌名称不能为空") + return + } + b, err := h.store.CreateBrand(r.Context(), auth.UserFrom(r.Context()), in.Name) + if h.handleBrandErr(w, err) { + return + } + writeJSON(w, http.StatusCreated, b) +} + +// UpdateBrand renames a brand. +func (h *Handler) UpdateBrand(w http.ResponseWriter, r *http.Request) { + var in brandInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + if strings.TrimSpace(in.Name) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "品牌名称不能为空") + return + } + b, err := h.store.UpdateBrand(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in.Name) + if h.handleBrandErr(w, err) { + return + } + writeJSON(w, http.StatusOK, b) +} + +// MergeBrands folds one brand's products into another, then deletes the source. +func (h *Handler) MergeBrands(w http.ResponseWriter, r *http.Request) { + var in brandMergeInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + if strings.TrimSpace(in.TargetID) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "请选择合并目标品牌") + return + } + b, err := h.store.MergeBrands(r.Context(), chi.URLParam(r, "id"), in.TargetID, auth.UserFrom(r.Context())) + if h.handleBrandErr(w, err) { + return + } + writeJSON(w, http.StatusOK, b) +} + +// DeleteBrand removes a brand no product references. +func (h *Handler) DeleteBrand(w http.ResponseWriter, r *http.Request) { + err := h.store.DeleteBrand(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context())) + if h.handleBrandErr(w, err) { + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// handleBrandErr maps brand-specific errors to client statuses. +func (h *Handler) handleBrandErr(w http.ResponseWriter, err error) bool { + if err == nil { + return false + } + switch { + case errors.Is(err, adminstore.ErrDuplicateBrand): + writeError(w, http.StatusConflict, "duplicate_brand", "该品牌名称已存在") + return true + case errors.Is(err, adminstore.ErrBrandInUse): + writeError(w, http.StatusConflict, "in_use", "仍有商品使用该品牌,请先改用其它品牌或合并") + return true + case errors.Is(err, adminstore.ErrInvalidMerge): + writeError(w, http.StatusBadRequest, "invalid_merge", "合并目标无效(不存在或与源品牌相同)") + return true + } + return h.handleErr(w, err) +} + // ---------- submissions ---------- // CreateSubmission accepts an anonymous public contribution into the queue. diff --git a/api/internal/adminstore/brand.go b/api/internal/adminstore/brand.go new file mode 100644 index 0000000..fea10d8 --- /dev/null +++ b/api/internal/adminstore/brand.go @@ -0,0 +1,154 @@ +package adminstore + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +// Brand-management errors, mapped to client statuses by the handler. +var ( + // ErrDuplicateBrand is returned when a brand name already exists. + ErrDuplicateBrand = errors.New("duplicate brand name") + // ErrBrandInUse blocks deleting a brand still referenced by products. + ErrBrandInUse = errors.New("brand in use") + // ErrInvalidMerge is returned when a merge target is missing or equal to + // the source. + ErrInvalidMerge = errors.New("invalid merge target") +) + +// CreateBrand inserts a new brand. Names are unique by normalized form. +func (s *Store) CreateBrand(ctx context.Context, actor, name string) (*Brand, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("name required") + } + var b Brand + err := s.pool.QueryRow(ctx, + `INSERT INTO brand (name, normalized_name) VALUES ($1, $2) RETURNING id, name, 0`, + name, normBrand(name)).Scan(&b.ID, &b.Name, &b.ProductCount) + if isUniqueViolation(err) { + return nil, ErrDuplicateBrand + } + if err != nil { + return nil, err + } + _ = s.writeAudit(ctx, actor, "create", "brand", &b.ID, []string{"name"}, nil, b) + return &b, nil +} + +// UpdateBrand renames a brand, keeping the normalized name in sync. +func (s *Store) UpdateBrand(ctx context.Context, id, actor, name string) (*Brand, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("name required") + } + ct, err := s.pool.Exec(ctx, + "UPDATE brand SET name = $1, normalized_name = $2 WHERE id = $3", + name, normBrand(name), id) + if isUniqueViolation(err) { + return nil, ErrDuplicateBrand + } + if err != nil { + return nil, err + } + if ct.RowsAffected() == 0 { + return nil, ErrNotFound + } + out, err := s.getBrand(ctx, id) + if err != nil { + return nil, err + } + _ = s.writeAudit(ctx, actor, "update", "brand", &id, []string{"name"}, nil, out) + return out, nil +} + +// MergeBrands reassigns every product of src to dst, then deletes src. Useful +// for collapsing duplicate brands (e.g. "可口可乐" and "Coca-Cola"). +func (s *Store) MergeBrands(ctx context.Context, srcID, dstID, actor string) (*Brand, error) { + if srcID == dstID { + return nil, ErrInvalidMerge + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var dstName string + err = tx.QueryRow(ctx, "SELECT name FROM brand WHERE id = $1", dstID).Scan(&dstName) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInvalidMerge + } + if err != nil { + return nil, err + } + var srcName string + err = tx.QueryRow(ctx, "SELECT name FROM brand WHERE id = $1", srcID).Scan(&srcName) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + if _, err := tx.Exec(ctx, "UPDATE product SET brand_id = $1 WHERE brand_id = $2", dstID, srcID); err != nil { + return nil, err + } + if _, err := tx.Exec(ctx, "DELETE FROM brand WHERE id = $1", srcID); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + out, err := s.getBrand(ctx, dstID) + if err != nil { + return nil, err + } + _ = s.writeAudit(ctx, actor, "merge", "brand", &srcID, []string{"name"}, + map[string]string{"name": srcName}, + map[string]string{"merged_into": dstName, "merged_into_id": dstID}) + return out, nil +} + +// DeleteBrand removes a brand not referenced by any product. +func (s *Store) DeleteBrand(ctx context.Context, id, actor string) error { + before, err := s.getBrand(ctx, id) + if err != nil { + return err + } + if before.ProductCount > 0 { + return fmt.Errorf("%w: %d products", ErrBrandInUse, before.ProductCount) + } + ct, err := s.pool.Exec(ctx, "DELETE FROM brand WHERE id = $1", id) + if err != nil { + if isForeignKeyViolation(err) { + return ErrBrandInUse + } + return err + } + if ct.RowsAffected() == 0 { + return ErrNotFound + } + _ = s.writeAudit(ctx, actor, "delete", "brand", &id, []string{"name"}, before, nil) + return nil +} + +func (s *Store) getBrand(ctx context.Context, id string) (*Brand, error) { + var b Brand + err := s.pool.QueryRow(ctx, ` +SELECT b.id, b.name, + (SELECT count(*) FROM product p WHERE p.brand_id = b.id) +FROM brand b WHERE b.id = $1`, id).Scan(&b.ID, &b.Name, &b.ProductCount) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &b, nil +} diff --git a/api/internal/adminstore/brand_test.go b/api/internal/adminstore/brand_test.go new file mode 100644 index 0000000..6be52e2 --- /dev/null +++ b/api/internal/adminstore/brand_test.go @@ -0,0 +1,78 @@ +package adminstore + +import ( + "context" + "errors" + "testing" +) + +func TestBrandLifecycle(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + a, err := s.CreateBrand(ctx, "tester", "品牌A "+randomHex(4)) + if err != nil { + t.Fatalf("create A: %v", err) + } + t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM brand WHERE id = $1", a.ID) }) + + b, err := s.CreateBrand(ctx, "tester", "品牌B "+randomHex(4)) + if err != nil { + t.Fatalf("create B: %v", err) + } + t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM brand WHERE id = $1", b.ID) }) + + // Duplicate (normalized) name must be rejected. + if _, err := s.CreateBrand(ctx, "tester", " "+a.Name+" "); !errors.Is(err, ErrDuplicateBrand) { + t.Fatalf("expected ErrDuplicateBrand, got %v", err) + } + + // Rename. + renamed, err := s.UpdateBrand(ctx, a.ID, "tester", "品牌A改名") + if err != nil { + t.Fatalf("rename: %v", err) + } + if renamed.Name != "品牌A改名" { + t.Fatalf("rename not applied: %q", renamed.Name) + } + + // Attach a product to brand A so deletion is blocked and merge moves it. + var prodID string + err = s.pool.QueryRow(ctx, + "INSERT INTO product (name, brand_id, status) VALUES ($1,$2,'active') RETURNING id", + "测试商品 "+randomHex(4), a.ID).Scan(&prodID) + if err != nil { + t.Fatalf("insert product: %v", err) + } + t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM product WHERE id = $1", prodID) }) + + // Deleting an in-use brand must fail. + if err := s.DeleteBrand(ctx, a.ID, "tester"); !errors.Is(err, ErrBrandInUse) { + t.Fatalf("expected ErrBrandInUse, got %v", err) + } + + // Merge A into B: product reassigned, A deleted. + merged, err := s.MergeBrands(ctx, a.ID, b.ID, "tester") + if err != nil { + t.Fatalf("merge: %v", err) + } + if merged.ID != b.ID || merged.ProductCount < 1 { + t.Fatalf("merge result wrong: %+v", merged) + } + if _, err := s.getBrand(ctx, a.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("source brand should be gone, got %v", err) + } + + // Self-merge is invalid. + if _, err := s.MergeBrands(ctx, b.ID, b.ID, "tester"); !errors.Is(err, ErrInvalidMerge) { + t.Fatalf("expected ErrInvalidMerge, got %v", err) + } + + // After moving the product away from B, B can be deleted. + if _, err := s.pool.Exec(ctx, "DELETE FROM product WHERE id = $1", prodID); err != nil { + t.Fatalf("cleanup product: %v", err) + } + if err := s.DeleteBrand(ctx, b.ID, "tester"); err != nil { + t.Fatalf("delete unused brand: %v", err) + } +} diff --git a/api/internal/adminstore/write.go b/api/internal/adminstore/write.go index 238cdef..9a6a4dd 100644 --- a/api/internal/adminstore/write.go +++ b/api/internal/adminstore/write.go @@ -170,6 +170,79 @@ ON CONFLICT (product_id) DO UPDATE SET return after, nil } +// ErrDuplicateGTIN is returned when a product GTIN already exists. +var ErrDuplicateGTIN = errors.New("duplicate gtin") + +// CreateProduct inserts a new product from the admin UI. Only the core fields +// are required; the operator completes the rest in the detail editor. +func (s *Store) CreateProduct(ctx context.Context, actor string, in ProductInput) (*ProductDetail, error) { + if strings.TrimSpace(in.Name) == "" { + return nil, errors.New("name required") + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + brandID := in.BrandID + if in.BrandName != nil && strings.TrimSpace(*in.BrandName) != "" { + bid, err := s.ensureBrand(ctx, tx, strings.TrimSpace(*in.BrandName)) + if err != nil { + return nil, err + } + brandID = &bid + } + + var gpc *string + if in.CategoryID != nil && *in.CategoryID != "" { + if err := tx.QueryRow(ctx, "SELECT gpc_brick_code FROM category WHERE id = $1", *in.CategoryID).Scan(&gpc); err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + } + + canonical, err := s.netCanonical(ctx, tx, in.NetContentValue, in.NetContentUnit) + if err != nil { + return nil, err + } + + status := in.Status + if status == "" { + status = "active" + } + + var id string + err = tx.QueryRow(ctx, ` +INSERT INTO product (gtin, name, brand_id, category_id, gpc_brick_code, + net_content_value, net_content_unit, net_content_canonical, + country_of_origin, status) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) +RETURNING id`, + in.GTIN, in.Name, brandID, in.CategoryID, gpc, + in.NetContentValue, in.NetContentUnit, canonical, + in.CountryOfOrigin, status).Scan(&id) + if isUniqueViolation(err) { + return nil, ErrDuplicateGTIN + } + if err != nil { + return nil, err + } + + if _, err := s.recomputeQualityTx(ctx, tx, id); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + after, err := s.GetProduct(ctx, id) + if err != nil { + return nil, err + } + _ = s.writeAudit(ctx, actor, "create", "product", &id, []string{"name"}, nil, after) + return after, nil +} + func strEq(a, b *string) bool { if a == nil && b == nil { return true @@ -330,15 +403,21 @@ func (s *Store) DeleteMSRP(ctx context.Context, productID, msrpID, actor string) // ---------- dictionaries ---------- -// Brand is a brand option for the edit form. +// Brand is a brand option for the edit form and the management view. type Brand struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + ProductCount int `json:"product_count"` } -// ListBrands returns all brands ordered by name. +// ListBrands returns all brands ordered by name, with the number of products +// referencing each one. func (s *Store) ListBrands(ctx context.Context) ([]Brand, error) { - rows, err := s.pool.Query(ctx, "SELECT id, name FROM brand ORDER BY name") + rows, err := s.pool.Query(ctx, ` +SELECT b.id, b.name, + (SELECT count(*) FROM product p WHERE p.brand_id = b.id) AS product_count +FROM brand b +ORDER BY b.name`) if err != nil { return nil, err } @@ -346,7 +425,7 @@ func (s *Store) ListBrands(ctx context.Context) ([]Brand, error) { out := []Brand{} for rows.Next() { var b Brand - if err := rows.Scan(&b.ID, &b.Name); err != nil { + if err := rows.Scan(&b.ID, &b.Name, &b.ProductCount); err != nil { return nil, err } out = append(out, b)