From 0036b55618fdeed012ecd3da4ba31176911d115c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 05:18:54 +0200 Subject: [PATCH] feat(admin): product catalog editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /_gm console gains a Catalog editor — the source of truth for products. Create / edit / archive-unarchive products, their atom composition and per-rail prices (RUB via direct / VOTE via vk / XTR via telegram / CHIP value), and hard-delete only a never-transacted product (an order or ledger reference forces archive-only, backed by the FK RESTRICT). The archived flag reuses the existing product.active. Activation revalidates the sellable shape — a pack (the chips atom ⇒ a money price per rail, chips-only) or a value (no chips ⇒ a CHIP price); a tournament-bearing product is composable but never sellable yet. Backed by payments AdminCatalog / CreateProduct / UpdateProduct / SetProductActive / DeleteProduct + a pure validateProduct. Tests: validateProduct (pack / value / tournament / duplicate / shape); the console editor end to end (create, edit, archive, delete-if-clean, refuse a transacted delete). --- backend/README.md | 6 +- .../adminconsole/templates/layout.gohtml | 1 + .../templates/pages/catalog.gohtml | 44 ++++ .../templates/pages/product_detail.gohtml | 25 ++ backend/internal/adminconsole/views.go | 48 ++++ .../internal/inttest/catalog_editor_test.go | 97 ++++++++ backend/internal/payments/catalog_admin.go | 191 +++++++++++++++ .../internal/payments/catalog_admin_test.go | 47 ++++ .../internal/payments/store_catalog_admin.go | 218 ++++++++++++++++++ .../internal/server/handlers_admin_catalog.go | 183 +++++++++++++++ .../internal/server/handlers_admin_console.go | 8 + 11 files changed, 867 insertions(+), 1 deletion(-) create mode 100644 backend/internal/adminconsole/templates/pages/catalog.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/product_detail.gohtml create mode 100644 backend/internal/inttest/catalog_editor_test.go create mode 100644 backend/internal/payments/catalog_admin.go create mode 100644 backend/internal/payments/catalog_admin_test.go create mode 100644 backend/internal/payments/store_catalog_admin.go create mode 100644 backend/internal/server/handlers_admin_catalog.go diff --git a/backend/README.md b/backend/README.md index e77d444..bf3adcf 100644 --- a/backend/README.md +++ b/backend/README.md @@ -142,7 +142,11 @@ publishes a `notify` `banner` re-poll signal so the client shows/hides it in pla The same gate drives the post-move interstitial config (`Profile.ads`, `adsFor`). The user card also carries a **finance panel** (`payments.AccountStatement`): the account's chip balances per funding segment, benefits per origin, the recorded refund risk, and the append-only ledger history -(newest first) — read straight from the payments tables, uncached. The shared wire +(newest first) — read straight from the payments tables, uncached. The **catalog editor** +(`/_gm/catalog`, `handlers_admin_catalog.go`) is the source of truth for products (D32): create / +edit / archive-unarchive (the `product.active` flag) products, their atoms and per-rail prices, and +hard-delete only a **never-transacted** product (an order/ledger reference forces archive-only, +backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The shared wire contracts live in the sibling [`../pkg`](../pkg) module. **Account linking & merge** (`/api/v1/user/link/*`). `internal/link` diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml index 4fab715..c472e03 100644 --- a/backend/internal/adminconsole/templates/layout.gohtml +++ b/backend/internal/adminconsole/templates/layout.gohtml @@ -21,6 +21,7 @@ Throttled Reasons Banners + Catalog Dictionary Broadcast Grafana ↗ diff --git a/backend/internal/adminconsole/templates/pages/catalog.gohtml b/backend/internal/adminconsole/templates/pages/catalog.gohtml new file mode 100644 index 0000000..a738175 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/catalog.gohtml @@ -0,0 +1,44 @@ +{{define "content" -}} +

Product catalog

+{{with .Data}} +

A pack funds chips (a money price per rail — RUB via direct, VOTE via vk, XTR via telegram); a value buys benefits with chips (a CHIP price). Archived products are hidden from players but still credit an in-flight payment and can be granted. A product with transactions can only be archived, not deleted. Amounts are in minor units (RUB kopecks; VOTE/XTR/CHIP whole). The tournament atom is not sellable yet — keep such a product archived.

+

Add product

+
+ +
Atoms (quantity; blank = none) + + + + +
+
Prices (minor units; blank = none) + + + + +
+ +
+
+
+

Products

+ + + +{{range .Products}} + + + + + + + +{{else}}{{end}} + +
TitleStatusAtomsPrices
{{.Title}}{{if .Active}}active{{else}}archived{{end}}{{if .Transacted}} transacted{{end}}{{range .Atoms}}{{.Atom}}×{{.Quantity}} {{end}}{{range .Prices}}{{.Currency}}{{if .Method}}/{{.Method}}{{end}} {{.Amount}} {{end}} +
+{{if not .Transacted}}
{{end}} +
no products
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/product_detail.gohtml b/backend/internal/adminconsole/templates/pages/product_detail.gohtml new file mode 100644 index 0000000..369a042 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/product_detail.gohtml @@ -0,0 +1,25 @@ +{{define "content" -}} +{{with .Data}} +

← all products

+

{{.Title}} {{if .Active}}active{{else}}archived{{end}}{{if .Transacted}} transacted{{end}}

+

Edit

+

A zero quantity / blank price removes that atom / price. Amounts are in minor units. Saving revalidates the sellable shape when the product is active. Archive / unarchive from the catalog list.

+
+ +
Atoms (quantity; 0 = none) + + + + +
+
Prices (minor units; 0 = none) + + + + +
+
+
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 7fddc20..c1d5e66 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -653,3 +653,51 @@ type FeedbackDetailView struct { UserTZ string Banned bool } + +// CatalogView is the product-catalog list page. +type CatalogView struct { + Products []ProductRow +} + +// ProductRow is one product in the catalog list: its composition, prices, the archived flag +// (Active) and the transacted flag (which forbids a hard delete). +type ProductRow struct { + ID string + Title string + Active bool + Atoms []AtomRow + Prices []PriceRow + Transacted bool +} + +// AtomRow is one atom line of a product row. +type AtomRow struct { + Atom string + Quantity int +} + +// PriceRow is one price of a product: the method ("" for a value's CHIP price), the currency, and +// the amount in that currency's minor units. +type PriceRow struct { + Method string + Currency string + Amount int64 +} + +// ProductFormView is the product edit form, pre-filled from the current composition. Atom quantities +// and prices are flattened to the fixed fields the form offers (0 = absent); Transacted disables the +// delete action. +type ProductFormView struct { + ID string + Title string + Active bool + Chips int + Hints int + NoAds int + Tournament int + PriceRUB int64 + PriceVote int64 + PriceStar int64 + PriceChip int64 + Transacted bool +} diff --git a/backend/internal/inttest/catalog_editor_test.go b/backend/internal/inttest/catalog_editor_test.go new file mode 100644 index 0000000..02379a7 --- /dev/null +++ b/backend/internal/inttest/catalog_editor_test.go @@ -0,0 +1,97 @@ +//go:build integration + +package inttest + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/google/uuid" + + "scrabble/backend/internal/payments" +) + +// productByTitle finds a catalog product by its (unique in the test) title. +func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct { + t.Helper() + all, err := pay.AdminCatalog(context.Background()) + if err != nil { + t.Fatalf("admin catalog: %v", err) + } + for _, p := range all { + if p.Title == title { + return p + } + } + t.Fatalf("product %q not found", title) + return payments.AdminProduct{} +} + +// TestConsoleCatalogEditor drives the catalog editor end to end: create is CSRF-guarded; a value and +// a pack are created and edited; an invalid active product is refused; archive hides it; a +// never-transacted product deletes; a transacted product is refused deletion (archive only). +func TestConsoleCatalogEditor(t *testing.T) { + ctx := context.Background() + srv, _, pay := bannerServer(t) + h := srv.Handler() + const origin = "http://admin.test" + const catalog = "http://admin.test/_gm/catalog" + + // CSRF: a create without the origin header is refused. + if code, _ := consoleDo(h, http.MethodPost, catalog, "title=x&hints=1&price_chip=10&active=true", ""); code != http.StatusForbidden { + t.Fatalf("create without origin = %d, want 403", code) + } + + // Create a value product: 5 hints for 50 chips, active. + if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-hints-5&hints=5&price_chip=50&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Created") { + t.Fatalf("create value = %d, has 'Created' = %v", code, strings.Contains(body, "Created")) + } + val := productByTitle(t, pay, "cat-hints-5") + if !val.Active || len(val.Atoms) != 1 || val.Atoms[0].Atom != "hints" || val.Atoms[0].Quantity != 5 { + t.Fatalf("created value = %+v", val) + } + + // An invalid active pack (chips, no money price) is refused. + if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") { + t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product")) + } + if _, err := pay.AdminCatalog(ctx); err != nil { + t.Fatalf("catalog: %v", err) + } + + // Edit the value: raise to 8 hints. + base := catalog + "/" + val.ID.String() + if code, _ := consoleDo(h, http.MethodPost, base, "title=cat-hints-8&hints=8&price_chip=50&active=true", origin); code != http.StatusOK { + t.Fatalf("edit = %d", code) + } + if got := productByTitle(t, pay, "cat-hints-8"); len(got.Atoms) != 1 || got.Atoms[0].Quantity != 8 { + t.Fatalf("after edit atoms = %+v, want 8 hints", got.Atoms) + } + + // Archive it → hidden from the storefront (the user Catalog filters active). + if code, _ := consoleDo(h, http.MethodPost, base+"/archive", "active=false", origin); code != http.StatusOK { + t.Fatalf("archive = %d", code) + } + if productByTitle(t, pay, "cat-hints-8").Active { + t.Error("product still active after archive") + } + + // Delete the never-transacted product. + if code, body := consoleDo(h, http.MethodPost, base+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Deleted") { + t.Fatalf("delete clean = %d, has 'Deleted' = %v", code, strings.Contains(body, "Deleted")) + } + + // A transacted product cannot be deleted — only archived. + if code, _ := consoleDo(h, http.MethodPost, catalog, "title=cat-pack&chips=100&price_rub=14900&active=true", origin); code != http.StatusOK { + t.Fatal("create pack failed") + } + pack := productByTitle(t, pay, "cat-pack") + if _, err := pay.CreateOrder(ctx, uuid.New(), payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, pack.ID, "robokassa"); err != nil { + t.Fatalf("create order: %v", err) + } + if code, body := consoleDo(h, http.MethodPost, catalog+"/"+pack.ID.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Cannot delete") { + t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete")) + } +} diff --git a/backend/internal/payments/catalog_admin.go b/backend/internal/payments/catalog_admin.go new file mode 100644 index 0000000..999cbfb --- /dev/null +++ b/backend/internal/payments/catalog_admin.go @@ -0,0 +1,191 @@ +package payments + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// AdminProduct is one catalog product for the admin editor: its full composition, every price, the +// archived flag (Active), and whether it has ever been transacted — which forbids a hard delete +// (orders / ledger rows reference it, and the ledger is append-only). +type AdminProduct struct { + ID uuid.UUID + Title string + Active bool + Atoms []AtomLine + Prices []PriceLine + Transacted bool +} + +// AtomLine is one atom of a product: the atom type and its positive quantity. +type AtomLine struct { + Atom string + Quantity int +} + +// PriceLine is one price of a product: the payment method ("" for a value's CHIP price, which is +// stored with a NULL method), the currency, and the amount in that currency's minor units. +type PriceLine struct { + Method string + Currency Currency + Amount int64 +} + +// ProductInput is the editable content of a product: its title, atom composition and prices. +type ProductInput struct { + Title string + Atoms []AtomLine + Prices []PriceLine +} + +// knownAtoms is the fixed atom set, mirroring the catalog_atom seed / CHECK. +var knownAtoms = map[string]bool{atomChips: true, "hints": true, "noads_days": true, "tournament": true} + +// validCurrency reports whether c is one of the four catalog currencies. +func validCurrency(c Currency) bool { + switch c { + case CurrencyRUB, CurrencyVote, CurrencyStar, CurrencyChip: + return true + } + return false +} + +// validateProduct checks a product's composition and prices. When sellable is true (the product is +// or is becoming active) it also enforces the storefront shape: a pack (the chips atom ⇒ a money +// price per method and no CHIP price, chips-only) or a value (no chips ⇒ a single CHIP price and no +// money price). The tournament atom is never sellable (its economy is the tournament stage), so an +// active product may not carry it; an archived draft may (a template for later) and skips the shape +// check. +func validateProduct(in ProductInput, sellable bool) error { + if strings.TrimSpace(in.Title) == "" { + return errors.New("product title is required") + } + if len(in.Atoms) == 0 { + return errors.New("product needs at least one atom") + } + seen := map[string]bool{} + hasChips, hasTournament, hasBenefit := false, false, false + for _, a := range in.Atoms { + if !knownAtoms[a.Atom] { + return fmt.Errorf("unknown atom %q", a.Atom) + } + if seen[a.Atom] { + return fmt.Errorf("duplicate atom %q", a.Atom) + } + seen[a.Atom] = true + if a.Quantity <= 0 { + return fmt.Errorf("atom %q quantity must be positive", a.Atom) + } + switch a.Atom { + case atomChips: + hasChips = true + case "tournament": + hasTournament = true + default: + hasBenefit = true + } + } + + priceSeen := map[string]bool{} + hasChipPrice, hasMoneyPrice := false, false + for _, p := range in.Prices { + if p.Method != "" && p.Method != string(SourceVK) && p.Method != string(SourceTelegram) && p.Method != string(SourceDirect) { + return fmt.Errorf("invalid price method %q", p.Method) + } + if !validCurrency(p.Currency) { + return fmt.Errorf("invalid currency %q", p.Currency) + } + if p.Amount < 0 { + return errors.New("price amount must be non-negative") + } + if p.Currency == CurrencyChip && p.Method != "" { + return errors.New("a CHIP price must have no method") + } + if p.Currency != CurrencyChip && p.Method == "" { + return fmt.Errorf("a %s price needs a payment method", p.Currency) + } + key := p.Method + "|" + string(p.Currency) + if priceSeen[key] { + return fmt.Errorf("duplicate price (%s, %s)", p.Method, p.Currency) + } + priceSeen[key] = true + if p.Currency == CurrencyChip { + hasChipPrice = true + } else { + hasMoneyPrice = true + } + } + + if !sellable { + return nil // an archived draft may be incomplete + } + if hasTournament { + return errors.New("a product carrying the tournament atom cannot be sold yet") + } + if hasChips { + if hasBenefit { + return errors.New("a chip pack must contain only the chips atom") + } + if !hasMoneyPrice || hasChipPrice { + return errors.New("a chip pack needs a money price per method and no CHIP price") + } + } else { + if !hasChipPrice || hasMoneyPrice { + return errors.New("a value needs a single CHIP price and no money price") + } + } + return nil +} + +// AdminCatalog lists every product (active and archived) with its composition, prices and whether it +// has been transacted, for the admin editor. Read uncached, straight from the catalog tables. +func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) { + return s.store.adminCatalog(ctx) +} + +// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A +// product created active must satisfy the sellable shape. +func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) { + if err := validateProduct(in, active); err != nil { + return uuid.Nil, err + } + return s.store.createProduct(ctx, in, active, s.clock()) +} + +// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must +// satisfy the sellable shape. +func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInput) (err error) { + active, err := s.store.productActive(ctx, id) + if err != nil { + return err + } + if err := validateProduct(in, active); err != nil { + return err + } + return s.store.updateProduct(ctx, id, in, s.clock()) +} + +// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the +// sellable shape, so a draft or a tournament product cannot be put on sale. +func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active bool) error { + if active { + in, err := s.store.productInput(ctx, id) + if err != nil { + return err + } + if err := validateProduct(in, true); err != nil { + return err + } + } + return s.store.setProductActive(ctx, id, active, s.clock()) +} + +// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger +// row references it); otherwise it returns ErrProductTransacted and the caller archives instead. +func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error { + return s.store.deleteProduct(ctx, id) +} diff --git a/backend/internal/payments/catalog_admin_test.go b/backend/internal/payments/catalog_admin_test.go new file mode 100644 index 0000000..7a60a80 --- /dev/null +++ b/backend/internal/payments/catalog_admin_test.go @@ -0,0 +1,47 @@ +package payments + +import "testing" + +func TestValidateProduct(t *testing.T) { + pack := ProductInput{ + Title: "100 chips", + Atoms: []AtomLine{{Atom: "chips", Quantity: 100}}, + Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 14900}}, + } + value := ProductInput{ + Title: "5 hints", + Atoms: []AtomLine{{Atom: "hints", Quantity: 5}}, + Prices: []PriceLine{{Method: "", Currency: CurrencyChip, Amount: 50}}, + } + + tests := []struct { + name string + in ProductInput + sellable bool + wantErr bool + }{ + {"valid pack", pack, true, false}, + {"valid value", value, true, false}, + {"pack with a benefit atom", ProductInput{Title: "x", Atoms: []AtomLine{{"chips", 100}, {"hints", 5}}, Prices: pack.Prices}, true, true}, + {"pack without money price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: value.Prices}, true, true}, + {"value without chip price", ProductInput{Title: "x", Atoms: value.Atoms, Prices: pack.Prices}, true, true}, + {"tournament sellable is refused", ProductInput{Title: "cup", Atoms: []AtomLine{{"tournament", 1}}, Prices: value.Prices}, true, true}, + {"tournament draft is allowed", ProductInput{Title: "cup", Atoms: []AtomLine{{"tournament", 1}}}, false, false}, + {"unknown atom", ProductInput{Title: "x", Atoms: []AtomLine{{"gold", 1}}}, false, true}, + {"duplicate atom", ProductInput{Title: "x", Atoms: []AtomLine{{"hints", 1}, {"hints", 2}}}, false, true}, + {"non-positive quantity", ProductInput{Title: "x", Atoms: []AtomLine{{"hints", 0}}}, false, true}, + {"chip price with a method", ProductInput{Title: "x", Atoms: value.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyChip, Amount: 50}}}, true, true}, + {"money price without a method", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "", Currency: CurrencyRUB, Amount: 149}}}, true, true}, + {"duplicate price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 1}, {Method: "direct", Currency: CurrencyRUB, Amount: 2}}}, true, true}, + {"empty title", ProductInput{Title: " ", Atoms: value.Atoms, Prices: value.Prices}, true, true}, + {"no atoms", ProductInput{Title: "x", Prices: value.Prices}, true, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateProduct(tt.in, tt.sellable) + if (err != nil) != tt.wantErr { + t.Fatalf("validateProduct = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/backend/internal/payments/store_catalog_admin.go b/backend/internal/payments/store_catalog_admin.go new file mode 100644 index 0000000..c63f8c1 --- /dev/null +++ b/backend/internal/payments/store_catalog_admin.go @@ -0,0 +1,218 @@ +package payments + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" + + "scrabble/backend/internal/postgres/jet/payments/model" + "scrabble/backend/internal/postgres/jet/payments/table" +) + +// ErrProductTransacted is returned when a hard delete is attempted on a product an order or ledger +// row references — it must be archived instead, to keep the append-only ledger resolvable. +var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete") + +// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag. +func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) { + var prods []model.Product + if err := postgres.SELECT(table.Product.AllColumns). + FROM(table.Product). + ORDER_BY(table.Product.CreatedAt.ASC()). + QueryContext(ctx, s.db, &prods); err != nil { + return nil, fmt.Errorf("payments: admin list products: %w", err) + } + out := make([]AdminProduct, 0, len(prods)) + for _, p := range prods { + atoms, prices, err := s.productComposition(ctx, p.ProductID) + if err != nil { + return nil, err + } + transacted, err := s.productTransacted(ctx, p.ProductID) + if err != nil { + return nil, err + } + out = append(out, AdminProduct{ + ID: p.ProductID, Title: p.Title, Active: p.Active, + Atoms: atoms, Prices: prices, Transacted: transacted, + }) + } + return out, nil +} + +// productComposition reads one product's atom lines and price rows. +func (s *Store) productComposition(ctx context.Context, id uuid.UUID) ([]AtomLine, []PriceLine, error) { + var items []model.ProductItem + if err := postgres.SELECT(table.ProductItem.AllColumns). + FROM(table.ProductItem). + WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(id))). + ORDER_BY(table.ProductItem.AtomType.ASC()). + QueryContext(ctx, s.db, &items); err != nil { + return nil, nil, fmt.Errorf("payments: load items %s: %w", id, err) + } + atoms := make([]AtomLine, len(items)) + for i, it := range items { + atoms[i] = AtomLine{Atom: it.AtomType, Quantity: int(it.Quantity)} + } + var prs []model.ProductPrice + if err := postgres.SELECT(table.ProductPrice.AllColumns). + FROM(table.ProductPrice). + WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(id))). + QueryContext(ctx, s.db, &prs); err != nil { + return nil, nil, fmt.Errorf("payments: load prices %s: %w", id, err) + } + prices := make([]PriceLine, len(prs)) + for i, pr := range prs { + method := "" + if pr.Method != nil { + method = *pr.Method + } + prices[i] = PriceLine{Method: method, Currency: Currency(pr.Currency), Amount: pr.Amount} + } + return atoms, prices, nil +} + +// productTransacted reports whether any order or ledger row references the product. +func (s *Store) productTransacted(ctx context.Context, id uuid.UUID) (bool, error) { + var yes bool + if err := s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM payments.orders WHERE product_id=$1) + OR EXISTS(SELECT 1 FROM payments.ledger WHERE product_id=$1)`, id).Scan(&yes); err != nil { + return false, fmt.Errorf("payments: product transacted %s: %w", id, err) + } + return yes, nil +} + +// productActive reads a product's archived flag, ErrProductNotFound when it is missing. +func (s *Store) productActive(ctx context.Context, id uuid.UUID) (bool, error) { + var p model.Product + err := postgres.SELECT(table.Product.Active).FROM(table.Product). + WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1). + QueryContext(ctx, s.db, &p) + if errors.Is(err, qrm.ErrNoRows) { + return false, ErrProductNotFound + } + if err != nil { + return false, fmt.Errorf("payments: product active %s: %w", id, err) + } + return p.Active, nil +} + +// productInput reads a product's current editable content (title, atoms, prices). +func (s *Store) productInput(ctx context.Context, id uuid.UUID) (ProductInput, error) { + var p model.Product + err := postgres.SELECT(table.Product.AllColumns).FROM(table.Product). + WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1). + QueryContext(ctx, s.db, &p) + if errors.Is(err, qrm.ErrNoRows) { + return ProductInput{}, ErrProductNotFound + } + if err != nil { + return ProductInput{}, fmt.Errorf("payments: product input %s: %w", id, err) + } + atoms, prices, err := s.productComposition(ctx, id) + if err != nil { + return ProductInput{}, err + } + return ProductInput{Title: p.Title, Atoms: atoms, Prices: prices}, nil +} + +// createProduct inserts a product with its atoms and prices in one transaction, returning its id. +func (s *Store) createProduct(ctx context.Context, in ProductInput, active bool, now time.Time) (uuid.UUID, error) { + id := uuid.New() + if err := withTx(ctx, s.db, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, + `INSERT INTO payments.product (product_id, title, active, created_at, updated_at) VALUES ($1,$2,$3,$4,$4)`, + id, in.Title, active, now); err != nil { + return fmt.Errorf("insert product: %w", err) + } + return insertComposition(ctx, tx, id, in) + }); err != nil { + return uuid.Nil, fmt.Errorf("payments: create product: %w", err) + } + return id, nil +} + +// updateProduct replaces a product's title, atoms and prices in one transaction (active unchanged). +func (s *Store) updateProduct(ctx context.Context, id uuid.UUID, in ProductInput, now time.Time) error { + return withTx(ctx, s.db, func(tx *sql.Tx) error { + res, err := tx.ExecContext(ctx, + `UPDATE payments.product SET title=$2, updated_at=$3 WHERE product_id=$1`, id, in.Title, now) + if err != nil { + return fmt.Errorf("payments: update product %s: %w", id, err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrProductNotFound + } + if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_item WHERE product_id=$1`, id); err != nil { + return fmt.Errorf("payments: clear items %s: %w", id, err) + } + if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_price WHERE product_id=$1`, id); err != nil { + return fmt.Errorf("payments: clear prices %s: %w", id, err) + } + return insertComposition(ctx, tx, id, in) + }) +} + +// insertComposition inserts a product's atom items and price rows inside tx (a value's CHIP price +// carries a NULL method). +func insertComposition(ctx context.Context, tx *sql.Tx, id uuid.UUID, in ProductInput) error { + for _, a := range in.Atoms { + if _, err := tx.ExecContext(ctx, + `INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,$2,$3)`, + id, a.Atom, a.Quantity); err != nil { + return fmt.Errorf("insert item %s: %w", a.Atom, err) + } + } + for _, p := range in.Prices { + var method any + if p.Method != "" { + method = p.Method + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1,$2,$3,$4)`, + id, method, string(p.Currency), p.Amount); err != nil { + return fmt.Errorf("insert price %s: %w", p.Currency, err) + } + } + return nil +} + +// setProductActive flips the archived flag. +func (s *Store) setProductActive(ctx context.Context, id uuid.UUID, active bool, now time.Time) error { + res, err := s.db.ExecContext(ctx, + `UPDATE payments.product SET active=$2, updated_at=$3 WHERE product_id=$1`, id, active, now) + if err != nil { + return fmt.Errorf("payments: set product active %s: %w", id, err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrProductNotFound + } + return nil +} + +// deleteProduct hard-deletes a never-transacted product (its items/prices cascade); a transacted +// product is refused with ErrProductTransacted. +func (s *Store) deleteProduct(ctx context.Context, id uuid.UUID) error { + transacted, err := s.productTransacted(ctx, id) + if err != nil { + return err + } + if transacted { + return ErrProductTransacted + } + res, err := s.db.ExecContext(ctx, `DELETE FROM payments.product WHERE product_id=$1`, id) + if err != nil { + return fmt.Errorf("payments: delete product %s: %w", id, err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrProductNotFound + } + return nil +} diff --git a/backend/internal/server/handlers_admin_catalog.go b/backend/internal/server/handlers_admin_catalog.go new file mode 100644 index 0000000..bae1644 --- /dev/null +++ b/backend/internal/server/handlers_admin_catalog.go @@ -0,0 +1,183 @@ +package server + +import ( + "errors" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "scrabble/backend/internal/adminconsole" + "scrabble/backend/internal/payments" +) + +// catalogBack is the product-list path the catalog console actions return to. +const catalogBack = "/_gm/catalog" + +// atomField pairs a form field with its atom type; priceField pairs a form field with the payment +// method + currency it prices, following the one-currency-per-rail mapping (direct→RUB, vk→VOTE, +// telegram→XTR) plus the value's CHIP price (no method). +var atomFields = []struct{ field, atom string }{ + {"chips", "chips"}, {"hints", "hints"}, {"noads", "noads_days"}, {"tournament", "tournament"}, +} +var priceFields = []struct { + field, method string + currency payments.Currency +}{ + {"price_rub", "direct", payments.CurrencyRUB}, + {"price_vote", "vk", payments.CurrencyVote}, + {"price_star", "telegram", payments.CurrencyStar}, + {"price_chip", "", payments.CurrencyChip}, +} + +// consoleCatalog lists every product (active and archived) with its composition, prices, transacted +// flag, and the inline create form. +func (s *Server) consoleCatalog(c *gin.Context) { + products, err := s.payments.AdminCatalog(c.Request.Context()) + if err != nil { + s.consoleError(c, err) + return + } + var view adminconsole.CatalogView + for _, p := range products { + view.Products = append(view.Products, catalogRow(p)) + } + s.renderConsole(c, "catalog", "catalog", "Catalog", view) +} + +// catalogRow projects a product into its list row. +func catalogRow(p payments.AdminProduct) adminconsole.ProductRow { + row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted} + for _, a := range p.Atoms { + row.Atoms = append(row.Atoms, adminconsole.AtomRow{Atom: a.Atom, Quantity: a.Quantity}) + } + for _, pr := range p.Prices { + row.Prices = append(row.Prices, adminconsole.PriceRow{Method: pr.Method, Currency: string(pr.Currency), Amount: pr.Amount}) + } + return row +} + +// consoleCatalogDetail renders one product's edit form, pre-filled from its current composition. +func (s *Server) consoleCatalogDetail(c *gin.Context) { + id, ok := s.consoleUUID(c, catalogBack) + if !ok { + return + } + products, err := s.payments.AdminCatalog(c.Request.Context()) + if err != nil { + s.consoleError(c, err) + return + } + for _, p := range products { + if p.ID == id { + s.renderConsole(c, "product_detail", "catalog", p.Title, productForm(p)) + return + } + } + s.renderConsoleMessage(c, "Not found", "no such product", catalogBack) +} + +// productForm builds the pre-filled edit form for a product. +func productForm(p payments.AdminProduct) adminconsole.ProductFormView { + fv := adminconsole.ProductFormView{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted} + for _, a := range p.Atoms { + switch a.Atom { + case "chips": + fv.Chips = a.Quantity + case "hints": + fv.Hints = a.Quantity + case "noads_days": + fv.NoAds = a.Quantity + case "tournament": + fv.Tournament = a.Quantity + } + } + for _, pr := range p.Prices { + switch pr.Currency { + case payments.CurrencyRUB: + fv.PriceRUB = pr.Amount + case payments.CurrencyVote: + fv.PriceVote = pr.Amount + case payments.CurrencyStar: + fv.PriceStar = pr.Amount + case payments.CurrencyChip: + fv.PriceChip = pr.Amount + } + } + return fv +} + +// parseProductForm reads a product's title, atoms, prices and active flag from the submitted form. +func parseProductForm(c *gin.Context) (payments.ProductInput, bool) { + in := payments.ProductInput{Title: strings.TrimSpace(c.PostForm("title"))} + for _, a := range atomFields { + if q, err := strconv.Atoi(strings.TrimSpace(c.PostForm(a.field))); err == nil && q > 0 { + in.Atoms = append(in.Atoms, payments.AtomLine{Atom: a.atom, Quantity: q}) + } + } + for _, p := range priceFields { + if amt, err := strconv.ParseInt(strings.TrimSpace(c.PostForm(p.field)), 10, 64); err == nil && amt > 0 { + in.Prices = append(in.Prices, payments.PriceLine{Method: p.method, Currency: p.currency, Amount: amt}) + } + } + return in, c.PostForm("active") != "" +} + +// consoleCreateProduct validates and inserts a new product from the create form. +func (s *Server) consoleCreateProduct(c *gin.Context) { + in, active := parseProductForm(c) + if _, err := s.payments.CreateProduct(c.Request.Context(), in, active); err != nil { + s.renderConsoleMessage(c, "Invalid product", err.Error(), catalogBack) + return + } + s.renderConsoleMessage(c, "Created", "the product was created", catalogBack) +} + +// consoleUpdateProduct validates and replaces a product's title, atoms and prices. +func (s *Server) consoleUpdateProduct(c *gin.Context) { + id, ok := s.consoleUUID(c, catalogBack) + if !ok { + return + } + in, _ := parseProductForm(c) + back := catalogBack + "/" + id.String() + if err := s.payments.UpdateProduct(c.Request.Context(), id, in); err != nil { + s.renderConsoleMessage(c, "Invalid product", err.Error(), back) + return + } + s.renderConsoleMessage(c, "Saved", "the product was updated", back) +} + +// consoleArchiveProduct archives or unarchives a product (the desired state rides in the form); +// unarchiving revalidates the sellable shape. +func (s *Server) consoleArchiveProduct(c *gin.Context) { + id, ok := s.consoleUUID(c, catalogBack) + if !ok { + return + } + active := c.PostForm("active") == "true" + if err := s.payments.SetProductActive(c.Request.Context(), id, active); err != nil { + s.renderConsoleMessage(c, "Cannot change status", err.Error(), catalogBack) + return + } + s.renderConsoleMessage(c, "Updated", "the product status was changed", catalogBack) +} + +// consoleDeleteProductAction hard-deletes a never-transacted product; a transacted one is refused +// with a hint to archive instead. +func (s *Server) consoleDeleteProductAction(c *gin.Context) { + id, ok := s.consoleUUID(c, catalogBack) + if !ok { + return + } + err := s.payments.DeleteProduct(c.Request.Context(), id) + if errors.Is(err, payments.ErrProductTransacted) { + s.renderConsoleMessage(c, "Cannot delete", "this product has transactions; archive it instead", catalogBack) + return + } + if err != nil { + s.consoleError(c, err) + return + } + s.renderConsoleMessage(c, "Deleted", "the product was deleted", catalogBack) +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index a0c10a7..1853fd7 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -102,6 +102,14 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/banner-settings", s.consoleBannerSettings) gm.POST("/banner-settings", s.consoleUpdateBannerSettings) } + if s.payments != nil { + gm.GET("/catalog", s.consoleCatalog) + gm.POST("/catalog", s.consoleCreateProduct) + gm.GET("/catalog/:id", s.consoleCatalogDetail) + gm.POST("/catalog/:id", s.consoleUpdateProduct) + gm.POST("/catalog/:id/archive", s.consoleArchiveProduct) + gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction) + } } // consoleDashboard renders the landing page: the top-line counts and the resident -- 2.52.0