Files
scrabble-game/backend/internal/server/handlers_admin_catalog.go
T
Ilia Denisov b6af682381
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
refactor(payments): one canonical catalog order for storefront, offer, admin; admin active/all toggle
The wallet storefront listed products in creation order — its purchases and its
chip-exchange values were unsorted, unlike the admin console and the public offer.
Extract the ordering (chip packs first by rouble price, then chip-priced values
grouped hints → no-ads → combo → tournament and by chip price) into one comparator,
compareCatalogRank over a small rank key, and apply it at all three sites
(projectCatalog, projectOfferPricing, SortAdminCatalog) — so the subgroup ranking
(valueGroup) and the full order now live in one place. The rewarded "watch for
chips" CTA is a wallet-driven element rendered above the packs, so it is unaffected
and stays on top.

Also add the admin catalog active/all toggle: AdminCatalog takes includeInactive,
the console shows active products by default and ?all=1 lists archived ones too
(the detail and grant forms keep loading all products).

Tests: a storefront canonical-order unit test (reproduced the unsorted bug first);
an integration test for the active/all toggle. Existing offer/admin order tests
unchanged — behaviour preserved.
2026-07-14 10:23:42 +02:00

284 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"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 the catalog products with their composition, prices, transacted flag, and the
// inline create form. It shows active products by default; ?all=1 also lists the archived ones (the
// active/all toggle).
func (s *Server) consoleCatalog(c *gin.Context) {
showAll := c.Query("all") == "1"
products, err := s.payments.AdminCatalog(c.Request.Context(), showAll)
if err != nil {
s.consoleError(c, err)
return
}
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
payments.SortAdminCatalog(products)
view := adminconsole.CatalogView{ShowAll: showAll}
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(), true) // include archived: the detail form edits any product
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)
}
// consoleGrant grants raw benefit atoms (hints / no-ads days / forever) to a chosen origin — a
// zero-price admin sale.
func (s *Server) consoleGrant(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
if s.payments == nil {
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
return
}
origin := payments.Source(c.PostForm("origin"))
hints, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("hints")))
noads, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("noads")))
forever := c.PostForm("forever") != ""
if err := s.payments.Grant(c.Request.Context(), id, origin, hints, noads, forever); err != nil {
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
return
}
s.publishBannerChange(id)
s.renderConsoleMessage(c, "Granted", "the benefit was granted", back)
}
// consoleGrantProduct grants a value product's atoms (a reward bundle, possibly archived) to a
// chosen origin. It refuses a product carrying chips or the tournament atom (payments enforces it).
func (s *Server) consoleGrantProduct(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
if s.payments == nil {
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
return
}
origin := payments.Source(c.PostForm("origin"))
productID, err := uuid.Parse(strings.TrimSpace(c.PostForm("product_id")))
if err != nil {
s.renderConsoleMessage(c, "Grant failed", "choose a product", back)
return
}
if err := s.payments.GrantProduct(c.Request.Context(), id, origin, productID); err != nil {
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
return
}
s.publishBannerChange(id)
s.renderConsoleMessage(c, "Granted", "the product was granted", back)
}
// grantForm builds the admin-grant panel: the origin picker and the grantable products (value
// bundles, including archived ones — chips and tournament products are excluded).
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
products, err := s.payments.AdminCatalog(ctx, true)
if err != nil {
return fv
}
for _, p := range products {
if grantableProduct(p) {
fv.Products = append(fv.Products, adminconsole.GrantProductOption{
ID: p.ID.String(), Title: p.Title, Summary: atomSummary(p.Atoms), Archived: !p.Active,
})
}
}
return fv
}
// grantableProduct reports whether a product can be admin-granted: it carries at least one benefit
// atom (hints / no-ads days) and no chips or tournament atom.
func grantableProduct(p payments.AdminProduct) bool {
benefit := false
for _, a := range p.Atoms {
switch a.Atom {
case "chips", "tournament":
return false
case "hints", "noads_days":
benefit = true
}
}
return benefit
}
// atomSummary renders a product's atoms as "hints×5, noads_days×30".
func atomSummary(atoms []payments.AtomLine) string {
parts := make([]string, 0, len(atoms))
for _, a := range atoms {
parts = append(parts, fmt.Sprintf("%s×%d", a.Atom, a.Quantity))
}
return strings.Join(parts, ", ")
}