diff --git a/internal/handler/chat.go b/internal/handler/chat.go index 67b5740..64362f8 100644 --- a/internal/handler/chat.go +++ b/internal/handler/chat.go @@ -38,6 +38,8 @@ func writeChatError(w http.ResponseWriter, err error) { response.Error(w, http.StatusNotFound, err.Error()) case errors.Is(err, service.ErrChatBadEntity): response.Error(w, http.StatusBadRequest, err.Error()) + case errors.Is(err, repository.ErrStickerNotOwned): + response.Error(w, http.StatusForbidden, "that sticker isn't in your collection") case errors.Is(err, service.ErrChatBadEmoji): response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrChatBadQuote): @@ -206,6 +208,8 @@ type sendReq struct { // quote is a passage shared from the library. `path` must be app-relative; // the service refuses anything that could navigate off-site. Quote *quoteReq `json:"quote"` + // sticker_key sends reward artwork. Ownership is checked server-side. + StickerKey *string `json:"sticker_key"` // entity shares something else in the app — a post — as a card. Only the // reference; the BFF resolves the contents and re-checks visibility. Entity *entityReq `json:"entity"` @@ -249,6 +253,7 @@ func (h *ChatHandler) Send(w http.ResponseWriter, r *http.Request) { ReplyToID: req.ReplyToID, PreviewURL: req.PreviewURL, Quote: quote, + StickerKey: req.StickerKey, Entity: entity, Attachments: media, }) @@ -675,6 +680,38 @@ func (h *ChatHandler) MessageReactions(w http.ResponseWriter, r *http.Request) { response.JSON(w, http.StatusOK, list) } +// Stickers — GET /chat/stickers. What the caller may send. +func (h *ChatHandler) Stickers(w http.ResponseWriter, r *http.Request) { + list, err := h.svc.Stickers(r.Context(), middleware.UserID(r.Context())) + if err != nil { + writeChatError(w, err) + return + } + response.JSON(w, http.StatusOK, list) +} + +type saveStickerReq struct { + Key string `json:"key"` +} + +// SaveSticker — POST /chat/conversations/{id}/messages/{messageID}/sticker +// +// Keyed to the message it was sent on, not just the sticker key: that is what +// proves the caller was actually sent it rather than guessing at the catalog. +func (h *ChatHandler) SaveSticker(w http.ResponseWriter, r *http.Request) { + var req saveStickerReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + if err := h.svc.SaveSticker(r.Context(), middleware.UserID(r.Context()), + chi.URLParam(r, "messageID"), req.Key); err != nil { + writeChatError(w, err) + return + } + response.NoContent(w) +} + // Typing — POST /chat/conversations/{id}/typing // // Fire-and-forget: nothing is stored and the response carries nothing, because diff --git a/internal/handler/sadhna.go b/internal/handler/sadhna.go index 8816d32..a0e4480 100644 --- a/internal/handler/sadhna.go +++ b/internal/handler/sadhna.go @@ -2,11 +2,14 @@ package handler import ( "encoding/json" + "errors" "net/http" "time" + "github.com/go-chi/chi/v5" "github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/model" + "github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/pkg/response" ) @@ -114,6 +117,55 @@ func (h *SadhnaHandler) Rewards(w http.ResponseWriter, r *http.Request) { // AckRewards — POST /sadhna/rewards/ack. Clears pending celebrations once the // client has shown the confetti, so a gift surfaces exactly once. +// OpenGift — POST /sadhna/rewards/gifts/{key}/open +// +// Called after the celebration is dismissed. Idempotent: opening an already +// opened gift changes nothing. +func (h *SadhnaHandler) OpenGift(w http.ResponseWriter, r *http.Request) { + if err := h.svc.OpenGift(r.Context(), + middleware.UserID(r.Context()), chi.URLParam(r, "key")); err != nil { + response.Error(w, http.StatusInternalServerError, "could not open that gift") + return + } + response.NoContent(w) +} + +// GiftableRewards — GET /admin/reward-gifts/catalog +func (h *SadhnaHandler) GiftableRewards(w http.ResponseWriter, r *http.Request) { + list, err := h.svc.GiftableRewards(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, "could not load the catalog") + return + } + response.JSON(w, http.StatusOK, list) +} + +type giftReq struct { + RewardKey string `json:"reward_key"` +} + +// GiftToEveryone — POST /admin/reward-gifts +// +// Hands one reward to every account, unopened. Idempotent, so a repeated +// click tops up only the people who don't have it rather than duplicating. +func (h *SadhnaHandler) GiftToEveryone(w http.ResponseWriter, r *http.Request) { + var req giftReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RewardKey == "" { + response.Error(w, http.StatusBadRequest, "reward_key is required") + return + } + n, err := h.svc.GiftToEveryone(r.Context(), req.RewardKey) + if err != nil { + if errors.Is(err, repository.ErrRewardNotFound) { + response.Error(w, http.StatusNotFound, "no such reward") + return + } + response.Error(w, http.StatusInternalServerError, "could not send the gift") + return + } + response.JSON(w, http.StatusOK, map[string]int64{"granted": n}) +} + func (h *SadhnaHandler) AckRewards(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) if err := h.svc.AckRewards(r.Context(), uid); err != nil { diff --git a/internal/model/chat.go b/internal/model/chat.go index 81fef91..e209d94 100644 --- a/internal/model/chat.go +++ b/internal/model/chat.go @@ -171,11 +171,16 @@ const ( // app-relative deep link back to the phrase, title the source reference, // description the passage itself. AttachmentQuote = "quote" + // AttachmentSticker is reward artwork: image_url and title carry the art + // and its name, entity_id the reward key a recipient can save by. + AttachmentSticker = "sticker" ) // Entity types a message can reference. const ( EntityPost = "post" + // EntitySticker marks a sticker attachment's entity_id as a reward key. + EntitySticker = "sticker" ) // Attachment is a media file, link preview or shared entity on a message. @@ -257,6 +262,17 @@ type Message struct { Reactions []Reaction `json:"reactions"` } +// Sticker is one piece of reward artwork, as it appears in the picker and on +// a message. +type Sticker struct { + Key string `json:"key"` + Name string `json:"name"` + ImageURL string `json:"image_url"` + // Saved is true when it came from a chat rather than from sādhana, so the + // picker can say where it came from. + Saved bool `json:"saved"` +} + // ReactionDetail is one person's reaction, for the "who reacted" list. type ReactionDetail struct { User ChatPeer `json:"user"` diff --git a/internal/model/sadhna.go b/internal/model/sadhna.go index 38bfb0a..c09ef5f 100644 --- a/internal/model/sadhna.go +++ b/internal/model/sadhna.go @@ -52,9 +52,16 @@ type SadhnaReward struct { // SadhnaRewardGrant is a reward a user earned at a streak milestone (a // multiple of 7 days). The full reward is embedded for direct rendering. type SadhnaRewardGrant struct { - Milestone int `json:"milestone"` + // Milestone is the streak day this was earned on. Nil for an admin gift, + // which isn't tied to a streak at all. + Milestone *int `json:"milestone"` GrantedAt time.Time `json:"granted_at"` Reward SadhnaReward `json:"reward"` + // Source is "streak" or "gift". + Source string `json:"source"` + // Opened is false while a gift is still wrapped — the gallery renders + // those blurred until the devotee opens them. + Opened bool `json:"opened"` } // SadhnaDashboard is the single payload the /sadhna-streak page reads. It diff --git a/internal/repository/chat_repo.go b/internal/repository/chat_repo.go index 7f04450..37b1ea9 100644 --- a/internal/repository/chat_repo.go +++ b/internal/repository/chat_repo.go @@ -23,6 +23,9 @@ var ( ErrAttachmentGone = errors.New("attachment not found") ErrMessageNotHere = errors.New("message is not in this conversation") ErrUserGone = errors.New("user not found") + // ErrStickerNotOwned covers both "no such sticker" and "not yours" on + // purpose: the catalog shouldn't be enumerable by probing keys. + ErrStickerNotOwned = errors.New("sticker not available") ) type ChatRepo struct{ pg *Postgres } @@ -1227,6 +1230,101 @@ func (r *ChatRepo) ClearReaction(ctx context.Context, messageID, userID string) return conversationID, nil } +// Stickers lists everything a user may send: rewards they earned through +// sādhana, plus stickers they saved from a chat. +// +// One UNION rather than two calls and a merge in Go — "may I send this?" and +// "what can I send?" then have exactly one answer, and it stays correct if a +// user both earns and is sent the same sticker. +func (r *ChatRepo) Stickers(ctx context.Context, userID string) ([]model.Sticker, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT sr.key, sr.name, sr.image_url, bool_and(src.saved) AS saved + FROM ( + SELECT reward_id, FALSE AS saved FROM sadhna_reward_grants WHERE user_id = $1 + UNION ALL + SELECT reward_id, TRUE AS saved FROM saved_stickers WHERE user_id = $1 + ) src + JOIN sadhna_rewards sr ON sr.id = src.reward_id + WHERE sr.active + GROUP BY sr.key, sr.name, sr.image_url, sr.sort + ORDER BY sr.sort, sr.name`, userID) + if err != nil { + return nil, fmt.Errorf("stickers: %w", err) + } + defer rows.Close() + + out := []model.Sticker{} + for rows.Next() { + var s model.Sticker + if err := rows.Scan(&s.Key, &s.Name, &s.ImageURL, &s.Saved); err != nil { + return nil, fmt.Errorf("scan sticker: %w", err) + } + out = append(out, s) + } + return out, rows.Err() +} + +// Sticker looks up one piece of artwork a user is entitled to send. +// ErrStickerNotOwned when they have neither earned nor saved it. +func (r *ChatRepo) Sticker(ctx context.Context, userID, key string) (*model.Sticker, error) { + var s model.Sticker + err := r.pg.Pool.QueryRow(ctx, ` + SELECT sr.key, sr.name, sr.image_url + FROM sadhna_rewards sr + WHERE sr.key = $2 AND sr.active + AND (EXISTS (SELECT 1 FROM sadhna_reward_grants g + WHERE g.user_id = $1 AND g.reward_id = sr.id) + OR EXISTS (SELECT 1 FROM saved_stickers ss + WHERE ss.user_id = $1 AND ss.reward_id = sr.id))`, + userID, key).Scan(&s.Key, &s.Name, &s.ImageURL) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrStickerNotOwned + } + if err != nil { + return nil, fmt.Errorf("sticker: %w", err) + } + return &s, nil +} + +// SaveSticker keeps a sticker someone sent. Idempotent. +// +// Only stickers that actually appear on a message in a thread the caller is +// in can be saved — otherwise knowing a key would be enough to collect the +// whole catalog without earning any of it. +func (r *ChatRepo) SaveSticker(ctx context.Context, userID, key, fromMessageID string) error { + tag, err := r.pg.Pool.Exec(ctx, ` + INSERT INTO saved_stickers (user_id, reward_id, saved_from) + SELECT $1, sr.id, m.sender_id + FROM message_attachments a + JOIN messages m ON m.id = a.message_id + JOIN conversation_participants p + ON p.conversation_id = m.conversation_id AND p.user_id = $1 AND p.left_at IS NULL + JOIN sadhna_rewards sr ON sr.key = a.entity_id AND sr.active + WHERE a.message_id = $3 AND a.kind = 'sticker' AND a.entity_id = $2 + ON CONFLICT (user_id, reward_id) DO NOTHING`, + userID, key, fromMessageID) + if err != nil { + return fmt.Errorf("save sticker: %w", err) + } + // Zero rows means the sticker isn't on that message, or the caller isn't + // in the thread. Both are "you can't have this", not "already saved" — + // a repeat save conflicts instead, which also reports zero. + if tag.RowsAffected() == 0 { + var exists bool + if err := r.pg.Pool.QueryRow(ctx, ` + SELECT EXISTS (SELECT 1 FROM saved_stickers ss + JOIN sadhna_rewards sr ON sr.id = ss.reward_id + WHERE ss.user_id = $1 AND sr.key = $2)`, + userID, key).Scan(&exists); err != nil { + return fmt.Errorf("save sticker check: %w", err) + } + if !exists { + return ErrStickerNotOwned + } + } + return nil +} + // ReactionDetails lists everyone who reacted to one message, newest first. // // Separate from ReactionsFor: the grouped counts are what a thread renders, diff --git a/internal/repository/sadhna_reward_repo.go b/internal/repository/sadhna_reward_repo.go index 1a6295b..b8213cd 100644 --- a/internal/repository/sadhna_reward_repo.go +++ b/internal/repository/sadhna_reward_repo.go @@ -4,12 +4,17 @@ import ( "context" _ "embed" "encoding/json" + "errors" "fmt" "time" + "github.com/jackc/pgx/v5" "github.com/prabhupadasays/spsays-bff/internal/model" ) +// ErrRewardNotFound is an unknown or retired reward key. +var ErrRewardNotFound = errors.New("reward not found") + // rewardsSeedJSON is the catalog source of truth, kept in lockstep with the // repo-root sadhna-rewards/rewards.json (the upload tool writes both). It is // embedded so the BFF can seed/sync the catalog on startup with no external @@ -89,12 +94,14 @@ func (r *RewardRepo) ListGiftable(ctx context.Context) ([]model.SadhnaReward, er // reward embedded for direct rendering. func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, error) { rows, err := r.pg.Pool.Query(ctx, ` - SELECT g.milestone, g.granted_at, + SELECT g.milestone, g.granted_at, g.source, g.celebrated_at IS NOT NULL, w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort FROM sadhna_reward_grants g JOIN sadhna_rewards w ON w.id = g.reward_id WHERE g.user_id = $1 - ORDER BY g.granted_at DESC, g.milestone DESC`, + -- NULLS LAST so gifts, which have no milestone, sort among the rest + -- by date rather than jumping to the top. + ORDER BY g.granted_at DESC, g.milestone DESC NULLS LAST`, userID, ) if err != nil { @@ -105,7 +112,7 @@ func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.S for rows.Next() { var g model.SadhnaRewardGrant if err := rows.Scan( - &g.Milestone, &g.GrantedAt, + &g.Milestone, &g.GrantedAt, &g.Source, &g.Opened, &g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning, &g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort, ); err != nil { @@ -180,8 +187,64 @@ func (r *RewardRepo) CreateGrant(ctx context.Context, userID, rewardID string, m 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 + err := r.pg.Pool.QueryRow(ctx, ` + SELECT id, key, name, meaning, image_url, active, sort + FROM sadhna_rewards WHERE key = $1 AND active`, key, + ).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrRewardNotFound + } + if err != nil { + return nil, fmt.Errorf("reward by key: %w", err) + } + return &w, nil +} + +// GrantGiftToEveryone hands one reward to every account, unopened. +// +// Idempotent by the partial unique index: re-running adds it only to people +// who don't have it, so a mis-click can't create duplicate tiles. Returns how +// many accounts it reached. +func (r *RewardRepo) GrantGiftToEveryone(ctx context.Context, rewardID string) (int64, error) { + tag, err := r.pg.Pool.Exec(ctx, ` + INSERT INTO sadhna_reward_grants (user_id, reward_id, source) + SELECT u.id, $1, 'gift' + FROM users u + WHERE u.banned_at IS NULL + ON CONFLICT DO NOTHING`, rewardID) + if err != nil { + return 0, fmt.Errorf("grant gift: %w", err) + } + return tag.RowsAffected(), nil +} + +// OpenGift marks one gift as seen, which is what un-blurs its tile. +// +// Scoped to a single reward rather than clearing everything pending: a devotee +// with two gifts waiting should open them one at a time. +func (r *RewardRepo) OpenGift(ctx context.Context, userID, rewardKey string) error { + _, err := r.pg.Pool.Exec(ctx, ` + UPDATE sadhna_reward_grants g + SET celebrated_at = now() + FROM sadhna_rewards w + WHERE w.id = g.reward_id + AND g.user_id = $1 AND w.key = $2 + AND g.source = 'gift' AND g.celebrated_at IS NULL`, userID, rewardKey) + if err != nil { + return fmt.Errorf("open gift: %w", err) + } + return nil +} + // PendingGrant returns the most recent grant the client hasn't celebrated // yet (celebrated_at IS NULL), or nil. Drives the one-time confetti. +// +// STREAK grants only. An admin gift is also uncelebrated until opened, but it +// waits as a blurred tile in the gallery rather than ambushing the devotee +// with confetti the moment they load the page. func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.SadhnaRewardGrant, error) { var g model.SadhnaRewardGrant err := r.pg.Pool.QueryRow(ctx, ` @@ -189,7 +252,7 @@ func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.Sa w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort FROM sadhna_reward_grants g JOIN sadhna_rewards w ON w.id = g.reward_id - WHERE g.user_id = $1 AND g.celebrated_at IS NULL + WHERE g.user_id = $1 AND g.celebrated_at IS NULL AND g.source = 'streak' ORDER BY g.granted_at DESC, g.milestone DESC LIMIT 1`, userID, @@ -198,6 +261,7 @@ func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.Sa &g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning, &g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort, ) + g.Source = "streak" if err != nil { return nil, nil // no rows / nothing pending } diff --git a/internal/server/server.go b/internal/server/server.go index 967bd06..e6137b9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -935,6 +935,7 @@ func (s *Server) setupRouter() { r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) r.Get("/conversations", s.chatH.Inbox) r.Get("/unread", s.chatH.Unread) + r.Get("/stickers", s.chatH.Stickers) r.Post("/conversations/dm", s.chatH.OpenDM) r.Post("/conversations/group", s.chatH.CreateGroup) r.Post("/conversations/kautilya", s.chatH.OpenAIThread) @@ -964,6 +965,7 @@ func (s *Server) setupRouter() { r.Delete("/messages/{messageID}/star", s.chatH.Unstar) r.Get("/messages/{messageID}", s.chatH.Message) r.Get("/messages/{messageID}/reactions", s.chatH.MessageReactions) + r.Post("/messages/{messageID}/sticker", s.chatH.SaveSticker) r.Put("/messages/{messageID}/reaction", s.chatH.React) r.Delete("/messages/{messageID}/reaction", s.chatH.Unreact) r.Delete("/messages/{messageID}", s.chatH.DeleteMessage) @@ -1130,6 +1132,7 @@ func (s *Server) setupRouter() { r.Post("/chant", s.sadhnaH.SaveChant) r.Get("/rewards", s.sadhnaH.Rewards) r.Post("/rewards/ack", s.sadhnaH.AckRewards) + r.Post("/rewards/gifts/{key}/open", s.sadhnaH.OpenGift) if s.reminderH != nil { r.Get("/reminders", s.reminderH.GetPrefs) r.Put("/reminders", s.reminderH.UpdatePrefs) @@ -1158,6 +1161,9 @@ func (s *Server) setupRouter() { r.Get("/disciples", s.adminH.ListDisciples) // Announcements composed and published from the admin panel. + r.Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards) + r.Post("/reward-gifts", s.sadhnaH.GiftToEveryone) + r.Get("/broadcasts", s.broadcastH.List) r.Post("/broadcasts", s.broadcastH.Create) r.Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner) diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 6c02308..959535e 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -418,6 +418,9 @@ type SendRequest struct { PreviewURL *string // Entity is something else in the app — a post — shared as a card. Entity *EntityAttachment + // StickerKey sends a piece of reward artwork. Ownership is checked + // server-side; the key alone proves nothing. + StickerKey *string // Quote is a passage shared from the library, rendered as a quote the // reader can tap to return to the exact phrase. Quote *QuoteAttachment @@ -458,7 +461,8 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r body := strings.TrimSpace(req.Body) // A photo or a shared passage with no caption is an ordinary message; only // a message with nothing at all in it is empty. - if body == "" && len(req.Attachments) == 0 && req.Quote == nil && req.Entity == nil { + if body == "" && len(req.Attachments) == 0 && req.Quote == nil && + req.Entity == nil && req.StickerKey == nil { return nil, ErrChatEmptyBody } if len([]rune(body)) > maxBodyRunes { @@ -493,6 +497,12 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r return s.hydrateSent(ctx, userID, conversationID, msg), nil } + if req.StickerKey != nil { + if err := s.attachSticker(ctx, conversationID, userID, msg, *req.StickerKey); err != nil { + s.logger.Warn("chat: sticker refused", + zap.String("user_id", userID), zap.Error(err)) + } + } if err := s.attachEntity(ctx, conversationID, userID, msg, req.Entity); err != nil { s.logger.Warn("chat: entity share refused", zap.String("user_id", userID), zap.Error(err)) } @@ -1624,6 +1634,40 @@ func (s *ChatService) Message(ctx context.Context, userID, conversationID, messa return s.withAttachments(ctx, userID, msg) } +// Stickers lists what the caller can send: sādhana rewards they earned, plus +// stickers they saved from a chat. +func (s *ChatService) Stickers(ctx context.Context, userID string) ([]model.Sticker, error) { + return s.chat.Stickers(ctx, userID) +} + +// SaveSticker keeps one someone sent, after checking it really is on that +// message and the caller is in the thread. +func (s *ChatService) SaveSticker(ctx context.Context, userID, messageID, key string) error { + return s.chat.SaveSticker(ctx, userID, key, messageID) +} + +// attachSticker stores the artwork on a message. +// +// Ownership is checked here, not trusted from the client: the key comes over +// the wire, and without this anyone could send any sticker in the catalog by +// guessing its key. The image and name are denormalized because the catalog +// is static art — there is nothing viewer-dependent to re-resolve on read. +func (s *ChatService) attachSticker(ctx context.Context, conversationID, userID string, msg *model.Message, key string) error { + sticker, err := s.chat.Sticker(ctx, userID, key) + if err != nil { + return err + } + entityType := model.EntitySticker + if _, err := s.chat.AddAttachment(ctx, repository.AddAttachmentParams{ + MessageID: msg.ID, ConversationID: conversationID, Ord: 0, + Kind: model.AttachmentSticker, EntityType: &entityType, + EntityID: &sticker.Key, ImageURL: &sticker.ImageURL, Title: &sticker.Name, + }); err != nil { + s.logger.Warn("chat: attach sticker failed", zap.Error(err)) + } + return nil +} + // Typing broadcasts that someone is composing. // // Never persisted and never pushed: a typing indicator is only meaningful to diff --git a/internal/service/chat_service_test.go b/internal/service/chat_service_test.go index a803d53..1f40dda 100644 --- a/internal/service/chat_service_test.go +++ b/internal/service/chat_service_test.go @@ -2459,3 +2459,153 @@ func TestSend_ReplyCarriesItsQuoteImmediately(t *testing.T) { t.Error("a retried reply lost its quote") } } + +// grantSticker gives a user a reward the way sādhana does, returning its key. +func grantSticker(t *testing.T, pg *repository.Postgres, userID, key string) string { + t.Helper() + ctx := context.Background() + var rewardID string + if err := pg.Pool.QueryRow(ctx, ` + INSERT INTO sadhna_rewards (key, name, image_url) + VALUES ($1, $2, 'https://example.invalid/art.png') + ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name + RETURNING id`, key, "Sticker "+key).Scan(&rewardID); err != nil { + t.Fatalf("create reward: %v", err) + } + t.Cleanup(func() { + _, _ = pg.Pool.Exec(ctx, `DELETE FROM sadhna_rewards WHERE id = $1`, rewardID) + }) + if userID != "" { + // milestone is unique per user; a large random-ish one avoids clashing + // with any real streak rows. + if _, err := pg.Pool.Exec(ctx, ` + INSERT INTO sadhna_reward_grants (user_id, reward_id, milestone) + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, + userID, rewardID, 900000+len(key)); err != nil { + t.Fatalf("grant reward: %v", err) + } + } + return key +} + +func stickerAttachment(t *testing.T, msgs []model.Message) *model.Attachment { + t.Helper() + for i := range msgs { + for j := range msgs[i].Attachments { + if msgs[i].Attachments[j].Kind == model.AttachmentSticker { + return &msgs[i].Attachments[j] + } + } + } + return nil +} + +// A sticker key is not a capability: sending one you never earned must fail, +// or the whole catalog is available to anyone who can guess a key. +func TestStickers_OnlySendWhatYouOwn(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b := chatUser(t, pg, "A"), chatUser(t, pg, "B") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + mine := grantSticker(t, pg, a, "sticker_owned_"+a[:8]) + // Exists in the catalog, granted to nobody. + unearned := grantSticker(t, pg, "", "sticker_unearned_"+a[:8]) + + if _, err := svc.Send(ctx, a, conv.ID, SendRequest{StickerKey: &mine}); err != nil { + t.Fatalf("sending an earned sticker: %v", err) + } + msgs, _ := svc.Messages(ctx, a, conv.ID, 0, 10) + att := stickerAttachment(t, msgs) + if att == nil { + t.Fatal("an earned sticker did not attach") + } + if att.ImageURL == nil || *att.ImageURL == "" || att.EntityID == nil || *att.EntityID != mine { + t.Errorf("attachment = %+v, want the artwork and its key", att) + } + + // Not earned, not saved: the message sends, but carries no sticker. + if _, err := svc.Send(ctx, a, conv.ID, SendRequest{ + Body: "trying", StickerKey: &unearned, + }); err != nil { + t.Fatal(err) + } + msgs, _ = svc.Messages(ctx, a, conv.ID, 0, 10) + for i := range msgs { + for j := range msgs[i].Attachments { + if att := msgs[i].Attachments[j]; att.Kind == model.AttachmentSticker && + att.EntityID != nil && *att.EntityID == unearned { + t.Error("a sticker the sender never earned was attached") + } + } + } +} + +// Saving is how a sticker spreads — but only from a message actually sent to +// you, in a thread you are actually in. +func TestStickers_SaveFromChat(t *testing.T) { + svc, _, pg := testChatService(t) + ctx := context.Background() + a, b, outsider := chatUser(t, pg, "A"), chatUser(t, pg, "B"), chatUser(t, pg, "C") + conv, err := svc.OpenDM(ctx, a, b) + if err != nil { + t.Fatal(err) + } + cleanupConv(t, pg, conv.ID) + + key := grantSticker(t, pg, a, "sticker_shared_"+a[:8]) + sent, err := svc.Send(ctx, a, conv.ID, SendRequest{StickerKey: &key}) + if err != nil { + t.Fatal(err) + } + + // B has not earned it, so it isn't in their drawer yet. + before, _ := svc.Stickers(ctx, b) + for _, s := range before { + if s.Key == key { + t.Fatal("B has the sticker before saving it") + } + } + + if err := svc.SaveSticker(ctx, b, sent.ID, key); err != nil { + t.Fatalf("saving a sticker sent to you: %v", err) + } + after, _ := svc.Stickers(ctx, b) + var found *model.Sticker + for i := range after { + if after[i].Key == key { + found = &after[i] + } + } + if found == nil { + t.Fatal("saved sticker is not in B's drawer") + } + if !found.Saved { + t.Error("a sticker from a chat should be marked as saved, not earned") + } + // B can now send it themselves. + if _, err := svc.Send(ctx, b, conv.ID, SendRequest{StickerKey: &key}); err != nil { + t.Errorf("B should be able to send a saved sticker: %v", err) + } + // Saving twice is harmless. + if err := svc.SaveSticker(ctx, b, sent.ID, key); err != nil { + t.Errorf("second save should be idempotent: %v", err) + } + + // Someone outside the thread cannot save from it, even knowing both ids. + if err := svc.SaveSticker(ctx, outsider, sent.ID, key); !errors.Is(err, repository.ErrStickerNotOwned) { + t.Errorf("outsider save: err = %v, want ErrStickerNotOwned", err) + } + + // The sender's own drawer still reports it as earned, not saved. + mine, _ := svc.Stickers(ctx, a) + for _, s := range mine { + if s.Key == key && s.Saved { + t.Error("an earned sticker is being reported as saved") + } + } +} diff --git a/internal/service/gift_service_test.go b/internal/service/gift_service_test.go new file mode 100644 index 0000000..25f847d --- /dev/null +++ b/internal/service/gift_service_test.go @@ -0,0 +1,140 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" + "github.com/joho/godotenv" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "go.uber.org/zap" +) + +func testRewardRepo(t *testing.T) (*repository.RewardRepo, *repository.Postgres) { + t.Helper() + uri := os.Getenv("POSTGRES_URI") + if uri == "" { + _ = godotenv.Load(filepath.Join("..", "..", ".env")) + uri = os.Getenv("POSTGRES_URI") + } + if uri == "" { + t.Skip("POSTGRES_URI not set") + } + pg, err := repository.NewPostgres(context.Background(), uri, zap.NewNop()) + if err != nil { + t.Skipf("postgres unreachable: %v", err) + } + t.Cleanup(pg.Close) + return repository.NewRewardRepo(pg), pg +} + +func giftUser(t *testing.T, pg *repository.Postgres) string { + t.Helper() + id := uuid.NewString() + suffix := id[:8] + if _, err := pg.Pool.Exec(context.Background(), ` + INSERT INTO users (id, email, password_hash, name, handle) + VALUES ($1, $2, 'x', 'Gift User', $3)`, + id, "gift-"+suffix+"@example.invalid", "gift_"+suffix); err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + _, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id) + }) + return id +} + +func giftReward(t *testing.T, pg *repository.Postgres) string { + t.Helper() + key := "gift_" + uuid.NewString()[:8] + if _, err := pg.Pool.Exec(context.Background(), ` + INSERT INTO sadhna_rewards (key, name, image_url) + VALUES ($1, 'Occasional Gift', 'https://example.invalid/g.png')`, key); err != nil { + t.Fatalf("create reward: %v", err) + } + t.Cleanup(func() { + _, _ = pg.Pool.Exec(context.Background(), `DELETE FROM sadhna_rewards WHERE key = $1`, key) + }) + return key +} + +// A gift arrives wrapped, opens once, and lands in the vault — and re-gifting +// must not duplicate it. +func TestGift_ArrivesWrappedAndOpensOnce(t *testing.T) { + rewards, pg := testRewardRepo(t) + ctx := context.Background() + user := giftUser(t, pg) + key := giftReward(t, pg) + + reward, err := rewards.ByKey(ctx, key) + if err != nil { + t.Fatal(err) + } + if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil { + t.Fatal(err) + } + + find := func() (found bool, opened bool) { + grants, err := rewards.GrantsByUser(ctx, user) + if err != nil { + t.Fatal(err) + } + for _, g := range grants { + if g.Reward.Key == key { + if g.Source != "gift" { + t.Errorf("source = %q, want gift", g.Source) + } + if g.Milestone != nil { + t.Errorf("milestone = %v, want nil — a gift isn't a streak day", *g.Milestone) + } + return true, g.Opened + } + } + return false, false + } + + found, opened := find() + if !found { + t.Fatal("the gift did not reach an existing account") + } + if opened { + t.Error("a new gift should arrive wrapped") + } + + // It must NOT hijack the streak confetti — that stays for streak rewards. + if pending, _ := rewards.PendingGrant(ctx, user); pending != nil { + t.Errorf("a gift appeared as a pending streak celebration: %+v", pending) + } + + if err := rewards.OpenGift(ctx, user, key); err != nil { + t.Fatal(err) + } + if _, opened = find(); !opened { + t.Error("opening the gift did not un-blur it") + } + // Opening twice is harmless. + if err := rewards.OpenGift(ctx, user, key); err != nil { + t.Errorf("second open should be a no-op: %v", err) + } + + // Re-gifting tops up newcomers without duplicating anyone's tile. + if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil { + t.Fatal(err) + } + grants, _ := rewards.GrantsByUser(ctx, user) + n := 0 + for _, g := range grants { + if g.Reward.Key == key { + n++ + } + } + if n != 1 { + t.Errorf("holdings of the gift = %d, want 1", n) + } + // ...and it must not silently re-wrap what was already opened. + if _, opened = find(); !opened { + t.Error("re-gifting re-wrapped an already opened gift") + } +} diff --git a/internal/service/sadhna_service.go b/internal/service/sadhna_service.go index ccf7909..57eb00b 100644 --- a/internal/service/sadhna_service.go +++ b/internal/service/sadhna_service.go @@ -398,6 +398,28 @@ func (s *SadhnaService) Rewards(ctx context.Context, userID string) ([]model.Sad // AckRewards clears the user's pending celebration(s) after the client has // shown the confetti. +// OpenGift un-blurs one gift after the devotee has seen its celebration. +// +// Per gift, not per user: someone with two waiting should open them one at a +// time rather than have the first dismissal clear both. +func (s *SadhnaService) OpenGift(ctx context.Context, userID, rewardKey string) error { + return s.rewards.OpenGift(ctx, userID, rewardKey) +} + +// GiftToEveryone hands a reward to every account. Returns how many it reached. +func (s *SadhnaService) GiftToEveryone(ctx context.Context, rewardKey string) (int64, error) { + reward, err := s.rewards.ByKey(ctx, rewardKey) + if err != nil { + return 0, err + } + return s.rewards.GrantGiftToEveryone(ctx, reward.ID) +} + +// GiftableRewards is the catalog an admin picks from. +func (s *SadhnaService) GiftableRewards(ctx context.Context) ([]model.SadhnaReward, error) { + return s.rewards.ListGiftable(ctx) +} + func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error { return s.rewards.MarkCelebrated(ctx, userID) } diff --git a/migrations/0054_stickers.down.sql b/migrations/0054_stickers.down.sql new file mode 100644 index 0000000..2c797f4 --- /dev/null +++ b/migrations/0054_stickers.down.sql @@ -0,0 +1,14 @@ +DELETE FROM message_attachments WHERE kind = 'sticker'; + +ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_kind_check; +ALTER TABLE message_attachments + ADD CONSTRAINT message_attachments_kind_check + CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity', 'quote')); + +ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_entity_type_check; +ALTER TABLE message_attachments + ADD CONSTRAINT message_attachments_entity_type_check + CHECK (entity_type IS NULL OR + entity_type IN ('post', 'comment', 'profile', 'collection')); + +DROP TABLE IF EXISTS saved_stickers; diff --git a/migrations/0054_stickers.up.sql b/migrations/0054_stickers.up.sql new file mode 100644 index 0000000..13e5f07 --- /dev/null +++ b/migrations/0054_stickers.up.sql @@ -0,0 +1,43 @@ +-- Stickers in chat. +-- +-- A sticker IS a sādhana reward — same catalog, same artwork — so there is no +-- separate sticker table. What's new is a second way to come by one: someone +-- sends you theirs and you keep it. +-- +-- saved_stickers records only that second route. A reward you earned is +-- already yours through sadhna_reward_grants, and duplicating it here would +-- give two answers to "do I own this?". +CREATE TABLE IF NOT EXISTS saved_stickers ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reward_id UUID NOT NULL REFERENCES sadhna_rewards(id) ON DELETE CASCADE, + -- Who sent it. Kept for provenance, and SET NULL rather than CASCADE so + -- deleting an account doesn't take away stickers other people saved. + saved_from UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, reward_id) +); + +-- "What's in my sticker drawer?" — the whole reason this table exists. +CREATE INDEX IF NOT EXISTS saved_stickers_user_idx + ON saved_stickers (user_id, created_at DESC); + +-- A sticker message: kind 'sticker', with the artwork denormalized onto the +-- attachment's existing image_url/title columns. +-- +-- Denormalized rather than resolved per read, unlike a shared post: the +-- catalog is static art with no viewer-dependent visibility, so there is +-- nothing to re-check and a join on every message would buy nothing. +ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_kind_check; + +ALTER TABLE message_attachments + ADD CONSTRAINT message_attachments_kind_check + CHECK (kind IN ('image', 'video', 'audio', 'file', 'link', 'entity', 'quote', 'sticker')); + +-- entity_id carries the reward key on a sticker attachment, so a recipient can +-- save it without the client having to trust a URL it was handed. +ALTER TABLE message_attachments DROP CONSTRAINT IF EXISTS message_attachments_entity_type_check; + +ALTER TABLE message_attachments + ADD CONSTRAINT message_attachments_entity_type_check + CHECK (entity_type IS NULL OR + entity_type IN ('post', 'comment', 'profile', 'collection', 'sticker')); diff --git a/migrations/0055_reward_gifts.down.sql b/migrations/0055_reward_gifts.down.sql new file mode 100644 index 0000000..8ccf2bd --- /dev/null +++ b/migrations/0055_reward_gifts.down.sql @@ -0,0 +1,12 @@ +DELETE FROM sadhna_reward_grants WHERE source = 'gift'; + +DROP INDEX IF EXISTS sadhna_reward_grants_unopened_idx; +DROP INDEX IF EXISTS sadhna_reward_grants_gift_uniq; +DROP INDEX IF EXISTS sadhna_reward_grants_streak_uniq; + +ALTER TABLE sadhna_reward_grants ALTER COLUMN milestone SET NOT NULL; +ALTER TABLE sadhna_reward_grants + ADD CONSTRAINT sadhna_reward_grants_user_id_milestone_key UNIQUE (user_id, milestone); + +ALTER TABLE sadhna_reward_grants DROP CONSTRAINT IF EXISTS sadhna_reward_grants_source_check; +ALTER TABLE sadhna_reward_grants DROP COLUMN IF EXISTS source; diff --git a/migrations/0055_reward_gifts.up.sql b/migrations/0055_reward_gifts.up.sql new file mode 100644 index 0000000..9c618e8 --- /dev/null +++ b/migrations/0055_reward_gifts.up.sql @@ -0,0 +1,41 @@ +-- Occasional gift rewards, granted from the admin panel. +-- +-- A gift IS a reward grant — same catalog, same gallery, same vault — so it +-- reuses sadhna_reward_grants rather than introducing a parallel table that +-- every reader would then have to union in. What differs is only where it came +-- from, which `source` records. +-- +-- `celebrated_at` already means "the devotee has seen this", which is exactly +-- the claim flag a gift needs: unclaimed gifts render blurred, and dismissing +-- the celebration is what clears them. +ALTER TABLE sadhna_reward_grants + ADD COLUMN IF NOT EXISTS source VARCHAR(8) NOT NULL DEFAULT 'streak'; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sadhna_reward_grants_source_check') THEN + ALTER TABLE sadhna_reward_grants + ADD CONSTRAINT sadhna_reward_grants_source_check + CHECK (source IN ('streak', 'gift')); + END IF; +END $$; + +-- A gift is not tied to a streak day, so it has no milestone. +ALTER TABLE sadhna_reward_grants ALTER COLUMN milestone DROP NOT NULL; + +-- UNIQUE (user_id, milestone) was the streak rule: one reward per milestone. +-- It can't police gifts, which have no milestone — so it becomes a partial +-- index over streak rows, and gifts get their own rule: one of each reward +-- per person, so re-gifting is idempotent rather than a duplicate tile. +ALTER TABLE sadhna_reward_grants + DROP CONSTRAINT IF EXISTS sadhna_reward_grants_user_id_milestone_key; + +CREATE UNIQUE INDEX IF NOT EXISTS sadhna_reward_grants_streak_uniq + ON sadhna_reward_grants (user_id, milestone) WHERE source = 'streak'; + +CREATE UNIQUE INDEX IF NOT EXISTS sadhna_reward_grants_gift_uniq + ON sadhna_reward_grants (user_id, reward_id) WHERE source = 'gift'; + +-- "Which gifts is this devotee still to open?" — the blurred tiles. +CREATE INDEX IF NOT EXISTS sadhna_reward_grants_unopened_idx + ON sadhna_reward_grants (user_id) WHERE celebrated_at IS NULL;