feat(admin): admin grant — raw benefits and by-product reward bundles
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
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
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).
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
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"
|
||||
@@ -181,3 +184,95 @@ func (s *Server) consoleDeleteProductAction(c *gin.Context) {
|
||||
}
|
||||
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, ", ")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user