Files
scrabble-game/backend/internal/server/handlers_admin_catalog.go
T
Ilia Denisov d2d6955cbf
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
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 1m48s
feat(admin): admin grant — raw benefits and by-product reward bundles
The /_gm user card gains a Grant panel: grant raw benefit atoms (hints /
no-ads days / forever) or a defined value product (a reward bundle, including
an archived one), origin-picked. Both write an admin_grant ledger row via
payments.Grant / GrantProduct; the by-product grant records the source
product_id + snapshot. Both refuse a chips atom (never grant currency) or a
tournament atom (no credit target yet); chips/tournament products are also
kept out of the by-product picker.

Tests: the console grant end to end (raw, by-product, refuse a chips pack,
CSRF-guarded).
2026-07-10 05:35:55 +02:00

279 lines
9.2 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 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, ", ")
}