0036b55618
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
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).
184 lines
6.1 KiB
Go
184 lines
6.1 KiB
Go
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)
|
|
}
|