diff --git a/internal/handler/sadhna.go b/internal/handler/sadhna.go index 35a7f27..05cfa8c 100644 --- a/internal/handler/sadhna.go +++ b/internal/handler/sadhna.go @@ -141,6 +141,19 @@ func (h *SadhnaHandler) GiftableRewards(w http.ResponseWriter, r *http.Request) response.JSON(w, http.StatusOK, list) } +// DeleteCustomGift — DELETE /admin/reward-gifts/{key} +func (h *SadhnaHandler) DeleteCustomGift(w http.ResponseWriter, r *http.Request) { + if err := h.svc.DeleteCustomGift(r.Context(), chi.URLParam(r, "key")); err != nil { + if errors.Is(err, repository.ErrRewardNotFound) { + response.Error(w, http.StatusNotFound, "no such custom gift") + return + } + response.Error(w, http.StatusBadRequest, err.Error()) + return + } + response.NoContent(w) +} + type customGiftReq struct { Name string `json:"name"` Meaning string `json:"meaning"` diff --git a/internal/repository/sadhna_reward_repo.go b/internal/repository/sadhna_reward_repo.go index 06e4255..67ce426 100644 --- a/internal/repository/sadhna_reward_repo.go +++ b/internal/repository/sadhna_reward_repo.go @@ -206,6 +206,29 @@ func (r *RewardRepo) CreateCustom(ctx context.Context, key, name, meaning, image return &w, nil } +// DeleteCustom removes an admin-made reward from the catalog. +// +// Both sadhna_reward_grants.reward_id and saved_stickers.reward_id are +// ON DELETE CASCADE, so this also takes it out of every devotee's gallery and +// sticker vault. Stickers already SENT in a chat are unaffected: those copy +// the artwork onto the message rather than referencing the catalog, so history +// stays intact. +// +// Restricted to custom keys. A seeded reward would come straight back on the +// next Seed run, and deleting one would rip a streak reward out of the +// galleries of everyone who earned it. +func (r *RewardRepo) DeleteCustom(ctx context.Context, key string) error { + tag, err := r.pg.Pool.Exec(ctx, + `DELETE FROM sadhna_rewards WHERE key = $1 AND key LIKE 'custom\_%'`, key) + if err != nil { + return fmt.Errorf("delete custom reward: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrRewardNotFound + } + return nil +} + // ByKey looks up one reward from the catalog. func (r *RewardRepo) ByKey(ctx context.Context, key string) (*model.SadhnaReward, error) { var w model.SadhnaReward diff --git a/internal/server/server.go b/internal/server/server.go index 05b57b0..63f7603 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1165,6 +1165,7 @@ func (s *Server) setupRouter() { 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.Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift) r.Get("/broadcasts", s.broadcastH.List) r.Post("/broadcasts", s.broadcastH.Create) diff --git a/internal/service/gift_service_test.go b/internal/service/gift_service_test.go index 25f847d..aa93a57 100644 --- a/internal/service/gift_service_test.go +++ b/internal/service/gift_service_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "os" "path/filepath" "testing" @@ -138,3 +139,68 @@ func TestGift_ArrivesWrappedAndOpensOnce(t *testing.T) { t.Error("re-gifting re-wrapped an already opened gift") } } + +// Deleting a custom gift must clear it from every gallery it reached, and must +// refuse to touch the seeded catalog — a seeded reward would return on the +// next Seed run, after having been ripped out of people's galleries. +func TestGift_DeleteRemovesItFromEveryGallery(t *testing.T) { + rewards, pg := testRewardRepo(t) + ctx := context.Background() + user := giftUser(t, pg) + + // A custom reward, as the admin panel creates them. + key := "custom_probe_" + uuid.NewString()[:8] + if _, err := pg.Pool.Exec(ctx, ` + INSERT INTO sadhna_rewards (key, name, image_url) + VALUES ($1, 'Probe Gift', 'https://example.invalid/p.png')`, key); err != nil { + t.Fatal(err) + } + reward, err := rewards.ByKey(ctx, key) + if err != nil { + t.Fatal(err) + } + if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil { + t.Fatal(err) + } + + held := func() int { + grants, err := rewards.GrantsByUser(ctx, user) + if err != nil { + t.Fatal(err) + } + n := 0 + for _, g := range grants { + if g.Reward.Key == key { + n++ + } + } + return n + } + if held() != 1 { + t.Fatalf("setup: the gift did not reach the gallery") + } + + if err := rewards.DeleteCustom(ctx, key); err != nil { + t.Fatal(err) + } + if held() != 0 { + t.Error("deleting the reward left it in a devotee's gallery") + } + if _, err := rewards.ByKey(ctx, key); !errors.Is(err, repository.ErrRewardNotFound) { + t.Errorf("reward still in the catalog: %v", err) + } + + // Seeded rewards are off limits. + var seeded string + if err := pg.Pool.QueryRow(ctx, + `SELECT key FROM sadhna_rewards WHERE key NOT LIKE 'custom\_%' LIMIT 1`, + ).Scan(&seeded); err != nil { + t.Skip("no seeded rewards to test against") + } + if err := rewards.DeleteCustom(ctx, seeded); !errors.Is(err, repository.ErrRewardNotFound) { + t.Errorf("a seeded reward was deletable: %v", err) + } + if _, err := rewards.ByKey(ctx, seeded); err != nil { + t.Errorf("the seeded reward was removed: %v", err) + } +} diff --git a/internal/service/sadhna_service.go b/internal/service/sadhna_service.go index 1562b37..c5e5e70 100644 --- a/internal/service/sadhna_service.go +++ b/internal/service/sadhna_service.go @@ -478,6 +478,14 @@ func (s *SadhnaService) PresignGiftImage(ctx context.Context, contentType string return presignImage(ctx, s.r2, "reward-gifts", "custom", contentType) } +// DeleteCustomGift removes a custom sticker everywhere it is held. +func (s *SadhnaService) DeleteCustomGift(ctx context.Context, key string) error { + if !strings.HasPrefix(key, customKeyPrefix) { + return errors.New("only custom gifts can be deleted") + } + return s.rewards.DeleteCustom(ctx, key) +} + // GiftableRewards is the catalog an admin picks from. func (s *SadhnaService) GiftableRewards(ctx context.Context) ([]model.SadhnaReward, error) { return s.rewards.ListGiftable(ctx)