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 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) } // 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) 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, ", ") }