feat: custom stickers apis
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -140,6 +141,48 @@ func (h *SadhnaHandler) GiftableRewards(w http.ResponseWriter, r *http.Request)
|
|||||||
response.JSON(w, http.StatusOK, list)
|
response.JSON(w, http.StatusOK, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type customGiftReq struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Meaning string `json:"meaning"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCustomGift — POST /admin/reward-gifts/catalog
|
||||||
|
//
|
||||||
|
// Adds a sticker to the catalog. It reaches nobody until it is sent.
|
||||||
|
func (h *SadhnaHandler) CreateCustomGift(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req customGiftReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reward, err := h.svc.CreateCustomGift(r.Context(), req.Name, req.Meaning, req.ImageURL)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, reward)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignGiftImage — POST /admin/reward-gifts/image-presign
|
||||||
|
func (h *SadhnaHandler) PresignGiftImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upload, public, err := h.svc.PresignGiftImage(r.Context(), strings.TrimSpace(req.ContentType))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]string{
|
||||||
|
"upload_url": upload, "image_url": public,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type giftReq struct {
|
type giftReq struct {
|
||||||
RewardKey string `json:"reward_key"`
|
RewardKey string `json:"reward_key"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,6 +187,25 @@ func (r *RewardRepo) CreateGrant(ctx context.Context, userID, rewardID string, m
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateCustom adds a reward to the catalog from the admin panel.
|
||||||
|
//
|
||||||
|
// Custom entries survive re-seeding: Seed upserts only the keys in its
|
||||||
|
// embedded JSON, and these keys are namespaced away from those.
|
||||||
|
func (r *RewardRepo) CreateCustom(ctx context.Context, key, name, meaning, imageURL string) (*model.SadhnaReward, error) {
|
||||||
|
var w model.SadhnaReward
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO sadhna_rewards (key, name, meaning, image_url, active, sort)
|
||||||
|
-- Sorted after the seeded catalog so custom gifts don't reshuffle it.
|
||||||
|
VALUES ($1, $2, $3, $4, true, 1000)
|
||||||
|
RETURNING id, key, name, meaning, image_url, active, sort`,
|
||||||
|
key, name, meaning, imageURL,
|
||||||
|
).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create custom reward: %w", err)
|
||||||
|
}
|
||||||
|
return &w, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ByKey looks up one reward from the catalog.
|
// ByKey looks up one reward from the catalog.
|
||||||
func (r *RewardRepo) ByKey(ctx context.Context, key string) (*model.SadhnaReward, error) {
|
func (r *RewardRepo) ByKey(ctx context.Context, key string) (*model.SadhnaReward, error) {
|
||||||
var w model.SadhnaReward
|
var w model.SadhnaReward
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
if err := rewardRepo.Seed(context.Background()); err != nil {
|
if err := rewardRepo.Seed(context.Background()); err != nil {
|
||||||
logger.Warn("sadhna: could not seed reward catalog", zap.Error(err))
|
logger.Warn("sadhna: could not seed reward catalog", zap.Error(err))
|
||||||
}
|
}
|
||||||
sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, logger)
|
sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, r2, logger)
|
||||||
s.sadhnaH = handler.NewSadhnaHandler(sadhnaSvc)
|
s.sadhnaH = handler.NewSadhnaHandler(sadhnaSvc)
|
||||||
|
|
||||||
// Feedback + stats — share Postgres with the rest of /api/v1 and
|
// Feedback + stats — share Postgres with the rest of /api/v1 and
|
||||||
@@ -1163,6 +1163,8 @@ func (s *Server) setupRouter() {
|
|||||||
// Announcements composed and published from the admin panel.
|
// Announcements composed and published from the admin panel.
|
||||||
r.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
|
r.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
|
||||||
r.Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
|
r.Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
|
||||||
|
r.Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
|
||||||
|
r.Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage)
|
||||||
|
|
||||||
r.Get("/broadcasts", s.broadcastH.List)
|
r.Get("/broadcasts", s.broadcastH.List)
|
||||||
r.Post("/broadcasts", s.broadcastH.Create)
|
r.Post("/broadcasts", s.broadcastH.Create)
|
||||||
|
|||||||
@@ -223,7 +223,9 @@ func (s *BroadcastService) PresignBanner(ctx context.Context, contentType string
|
|||||||
if !ok || kind != model.AttachmentImage {
|
if !ok || kind != model.AttachmentImage {
|
||||||
return "", "", ErrChatBadMedia
|
return "", "", ErrChatBadMedia
|
||||||
}
|
}
|
||||||
return presignImage(ctx, s.r2, strings.TrimSuffix(bannerPrefix, "/"), "banner", contentType)
|
// Returns the KEY: broadcasts.banner_key stores a key, and withBanner
|
||||||
|
// builds the URL from it at read time.
|
||||||
|
return presignImageKey(ctx, s.r2, strings.TrimSuffix(bannerPrefix, "/"), "banner", contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *BroadcastService) withBanner(b *model.Broadcast, key *string) {
|
func (s *BroadcastService) withBanner(b *model.Broadcast, key *string) {
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
crand "crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
@@ -64,6 +69,9 @@ type SadhnaService struct {
|
|||||||
posts *repository.PostRepo
|
posts *repository.PostRepo
|
||||||
users *repository.UserRepo
|
users *repository.UserRepo
|
||||||
rewards *repository.RewardRepo
|
rewards *repository.RewardRepo
|
||||||
|
// r2 stores custom gift artwork. Optional: nil disables custom gifts
|
||||||
|
// rather than failing anything that already works.
|
||||||
|
r2 *repository.R2
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,9 +80,13 @@ func NewSadhnaService(
|
|||||||
posts *repository.PostRepo,
|
posts *repository.PostRepo,
|
||||||
users *repository.UserRepo,
|
users *repository.UserRepo,
|
||||||
rewards *repository.RewardRepo,
|
rewards *repository.RewardRepo,
|
||||||
|
r2 *repository.R2,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) *SadhnaService {
|
) *SadhnaService {
|
||||||
return &SadhnaService{logs: logs, posts: posts, users: users, rewards: rewards, logger: logger}
|
return &SadhnaService{
|
||||||
|
logs: logs, posts: posts, users: users, rewards: rewards,
|
||||||
|
r2: r2, logger: logger,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dayFmt = "2006-01-02"
|
const dayFmt = "2006-01-02"
|
||||||
@@ -415,6 +427,57 @@ func (s *SadhnaService) GiftToEveryone(ctx context.Context, rewardKey string) (i
|
|||||||
return s.rewards.GrantGiftToEveryone(ctx, reward.ID)
|
return s.rewards.GrantGiftToEveryone(ctx, reward.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// customKeyPrefix namespaces admin-made rewards away from the seeded catalog,
|
||||||
|
// so Seed can never collide with one.
|
||||||
|
const customKeyPrefix = "custom_"
|
||||||
|
|
||||||
|
var nonSlug = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
|
// customRewardKey derives a stable, unique key from a name.
|
||||||
|
//
|
||||||
|
// The random suffix is what makes it unique: two gifts may reasonably share a
|
||||||
|
// name, and a collision would overwrite the first one's artwork.
|
||||||
|
func customRewardKey(name string) (string, error) {
|
||||||
|
slug := strings.Trim(nonSlug.ReplaceAllString(strings.ToLower(name), "_"), "_")
|
||||||
|
if slug == "" {
|
||||||
|
slug = "gift"
|
||||||
|
}
|
||||||
|
if len(slug) > 32 {
|
||||||
|
slug = slug[:32]
|
||||||
|
}
|
||||||
|
suffix := make([]byte, 4)
|
||||||
|
if _, err := crand.Read(suffix); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return customKeyPrefix + slug + "_" + hex.EncodeToString(suffix), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCustomGift adds a new sticker to the catalog. It is not granted to
|
||||||
|
// anyone until it is sent.
|
||||||
|
func (s *SadhnaService) CreateCustomGift(ctx context.Context, name, meaning, imageURL string) (*model.SadhnaReward, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("a gift needs a name")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(imageURL) == "" {
|
||||||
|
return nil, errors.New("a gift needs an image")
|
||||||
|
}
|
||||||
|
key, err := customRewardKey(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.rewards.CreateCustom(ctx, key, name, strings.TrimSpace(meaning), imageURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PresignGiftImage issues an upload URL for custom sticker artwork.
|
||||||
|
//
|
||||||
|
// Returns the PUBLIC URL, not a key: sadhna_rewards.image_url stores an
|
||||||
|
// absolute address, and stickers render for every participant in a chat, so
|
||||||
|
// the artwork belongs in the public bucket alongside avatars and post images.
|
||||||
|
func (s *SadhnaService) PresignGiftImage(ctx context.Context, contentType string) (uploadURL, publicURL string, err error) {
|
||||||
|
return presignImage(ctx, s.r2, "reward-gifts", "custom", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
// GiftableRewards is the catalog an admin picks from.
|
// GiftableRewards is the catalog an admin picks from.
|
||||||
func (s *SadhnaService) GiftableRewards(ctx context.Context) ([]model.SadhnaReward, error) {
|
func (s *SadhnaService) GiftableRewards(ctx context.Context) ([]model.SadhnaReward, error) {
|
||||||
return s.rewards.ListGiftable(ctx)
|
return s.rewards.ListGiftable(ctx)
|
||||||
|
|||||||
@@ -775,6 +775,29 @@ func (s *CollectionService) CoverPresign(ctx context.Context, userID, contentTyp
|
|||||||
// presignImage builds an R2 presign for a user-scoped media key under the
|
// presignImage builds an R2 presign for a user-scoped media key under the
|
||||||
// given prefix. The random suffix invalidates browser caches when the image
|
// given prefix. The random suffix invalidates browser caches when the image
|
||||||
// is replaced.
|
// is replaced.
|
||||||
|
// presignImageKey is presignImage's sibling for callers that store the storage
|
||||||
|
// KEY rather than a URL — so the CDN domain can change without rewriting rows.
|
||||||
|
// Handing those callers a public URL silently doubles it at read time.
|
||||||
|
func presignImageKey(ctx context.Context, r2 *repository.R2, prefix, name, contentType string) (uploadURL, key string, err error) {
|
||||||
|
if r2 == nil {
|
||||||
|
return "", "", errors.New("media uploads are not configured")
|
||||||
|
}
|
||||||
|
ext, ok := imageExtensionFor(contentType)
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("unsupported content type: %s", contentType)
|
||||||
|
}
|
||||||
|
suffix := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(suffix); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
key = path.Join(prefix, name, hex.EncodeToString(suffix)+ext)
|
||||||
|
uploadURL, _, err = r2.PresignPut(ctx, key, contentType, 5*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return uploadURL, key, nil
|
||||||
|
}
|
||||||
|
|
||||||
func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) {
|
func presignImage(ctx context.Context, r2 *repository.R2, prefix, userID, contentType string) (uploadURL, publicURL string, err error) {
|
||||||
if r2 == nil {
|
if r2 == nil {
|
||||||
return "", "", errors.New("media uploads are not configured")
|
return "", "", errors.New("media uploads are not configured")
|
||||||
|
|||||||
Reference in New Issue
Block a user