feat: add stickers
This commit is contained in:
@@ -38,6 +38,8 @@ func writeChatError(w http.ResponseWriter, err error) {
|
|||||||
response.Error(w, http.StatusNotFound, err.Error())
|
response.Error(w, http.StatusNotFound, err.Error())
|
||||||
case errors.Is(err, service.ErrChatBadEntity):
|
case errors.Is(err, service.ErrChatBadEntity):
|
||||||
response.Error(w, http.StatusBadRequest, err.Error())
|
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):
|
case errors.Is(err, service.ErrChatBadEmoji):
|
||||||
response.Error(w, http.StatusBadRequest, err.Error())
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
case errors.Is(err, service.ErrChatBadQuote):
|
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;
|
// quote is a passage shared from the library. `path` must be app-relative;
|
||||||
// the service refuses anything that could navigate off-site.
|
// the service refuses anything that could navigate off-site.
|
||||||
Quote *quoteReq `json:"quote"`
|
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
|
// entity shares something else in the app — a post — as a card. Only the
|
||||||
// reference; the BFF resolves the contents and re-checks visibility.
|
// reference; the BFF resolves the contents and re-checks visibility.
|
||||||
Entity *entityReq `json:"entity"`
|
Entity *entityReq `json:"entity"`
|
||||||
@@ -249,6 +253,7 @@ func (h *ChatHandler) Send(w http.ResponseWriter, r *http.Request) {
|
|||||||
ReplyToID: req.ReplyToID,
|
ReplyToID: req.ReplyToID,
|
||||||
PreviewURL: req.PreviewURL,
|
PreviewURL: req.PreviewURL,
|
||||||
Quote: quote,
|
Quote: quote,
|
||||||
|
StickerKey: req.StickerKey,
|
||||||
Entity: entity,
|
Entity: entity,
|
||||||
Attachments: media,
|
Attachments: media,
|
||||||
})
|
})
|
||||||
@@ -675,6 +680,38 @@ func (h *ChatHandler) MessageReactions(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.JSON(w, http.StatusOK, list)
|
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
|
// Typing — POST /chat/conversations/{id}/typing
|
||||||
//
|
//
|
||||||
// Fire-and-forget: nothing is stored and the response carries nothing, because
|
// Fire-and-forget: nothing is stored and the response carries nothing, because
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"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/internal/service"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
"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
|
// AckRewards — POST /sadhna/rewards/ack. Clears pending celebrations once the
|
||||||
// client has shown the confetti, so a gift surfaces exactly once.
|
// 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) {
|
func (h *SadhnaHandler) AckRewards(w http.ResponseWriter, r *http.Request) {
|
||||||
uid := middleware.UserID(r.Context())
|
uid := middleware.UserID(r.Context())
|
||||||
if err := h.svc.AckRewards(r.Context(), uid); err != nil {
|
if err := h.svc.AckRewards(r.Context(), uid); err != nil {
|
||||||
|
|||||||
@@ -171,11 +171,16 @@ const (
|
|||||||
// app-relative deep link back to the phrase, title the source reference,
|
// app-relative deep link back to the phrase, title the source reference,
|
||||||
// description the passage itself.
|
// description the passage itself.
|
||||||
AttachmentQuote = "quote"
|
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.
|
// Entity types a message can reference.
|
||||||
const (
|
const (
|
||||||
EntityPost = "post"
|
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.
|
// Attachment is a media file, link preview or shared entity on a message.
|
||||||
@@ -257,6 +262,17 @@ type Message struct {
|
|||||||
Reactions []Reaction `json:"reactions"`
|
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.
|
// ReactionDetail is one person's reaction, for the "who reacted" list.
|
||||||
type ReactionDetail struct {
|
type ReactionDetail struct {
|
||||||
User ChatPeer `json:"user"`
|
User ChatPeer `json:"user"`
|
||||||
|
|||||||
@@ -52,9 +52,16 @@ type SadhnaReward struct {
|
|||||||
// SadhnaRewardGrant is a reward a user earned at a streak milestone (a
|
// SadhnaRewardGrant is a reward a user earned at a streak milestone (a
|
||||||
// multiple of 7 days). The full reward is embedded for direct rendering.
|
// multiple of 7 days). The full reward is embedded for direct rendering.
|
||||||
type SadhnaRewardGrant struct {
|
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"`
|
GrantedAt time.Time `json:"granted_at"`
|
||||||
Reward SadhnaReward `json:"reward"`
|
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
|
// SadhnaDashboard is the single payload the /sadhna-streak page reads. It
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ var (
|
|||||||
ErrAttachmentGone = errors.New("attachment not found")
|
ErrAttachmentGone = errors.New("attachment not found")
|
||||||
ErrMessageNotHere = errors.New("message is not in this conversation")
|
ErrMessageNotHere = errors.New("message is not in this conversation")
|
||||||
ErrUserGone = errors.New("user not found")
|
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 }
|
type ChatRepo struct{ pg *Postgres }
|
||||||
@@ -1227,6 +1230,101 @@ func (r *ChatRepo) ClearReaction(ctx context.Context, messageID, userID string)
|
|||||||
return conversationID, nil
|
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.
|
// ReactionDetails lists everyone who reacted to one message, newest first.
|
||||||
//
|
//
|
||||||
// Separate from ReactionsFor: the grouped counts are what a thread renders,
|
// Separate from ReactionsFor: the grouped counts are what a thread renders,
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"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
|
// 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
|
// 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
|
// 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.
|
// reward embedded for direct rendering.
|
||||||
func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, error) {
|
func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, error) {
|
||||||
rows, err := r.pg.Pool.Query(ctx, `
|
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
|
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
|
||||||
FROM sadhna_reward_grants g
|
FROM sadhna_reward_grants g
|
||||||
JOIN sadhna_rewards w ON w.id = g.reward_id
|
JOIN sadhna_rewards w ON w.id = g.reward_id
|
||||||
WHERE g.user_id = $1
|
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,
|
userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,7 +112,7 @@ func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.S
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var g model.SadhnaRewardGrant
|
var g model.SadhnaRewardGrant
|
||||||
if err := rows.Scan(
|
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.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
|
||||||
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
|
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -180,8 +187,64 @@ func (r *RewardRepo) CreateGrant(ctx context.Context, userID, rewardID string, m
|
|||||||
return nil
|
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
|
// PendingGrant returns the most recent grant the client hasn't celebrated
|
||||||
// yet (celebrated_at IS NULL), or nil. Drives the one-time confetti.
|
// 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) {
|
func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.SadhnaRewardGrant, error) {
|
||||||
var g model.SadhnaRewardGrant
|
var g model.SadhnaRewardGrant
|
||||||
err := r.pg.Pool.QueryRow(ctx, `
|
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
|
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
|
||||||
FROM sadhna_reward_grants g
|
FROM sadhna_reward_grants g
|
||||||
JOIN sadhna_rewards w ON w.id = g.reward_id
|
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
|
ORDER BY g.granted_at DESC, g.milestone DESC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
userID,
|
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.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
|
||||||
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
|
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
|
||||||
)
|
)
|
||||||
|
g.Source = "streak"
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil // no rows / nothing pending
|
return nil, nil // no rows / nothing pending
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -935,6 +935,7 @@ func (s *Server) setupRouter() {
|
|||||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
r.Get("/conversations", s.chatH.Inbox)
|
r.Get("/conversations", s.chatH.Inbox)
|
||||||
r.Get("/unread", s.chatH.Unread)
|
r.Get("/unread", s.chatH.Unread)
|
||||||
|
r.Get("/stickers", s.chatH.Stickers)
|
||||||
r.Post("/conversations/dm", s.chatH.OpenDM)
|
r.Post("/conversations/dm", s.chatH.OpenDM)
|
||||||
r.Post("/conversations/group", s.chatH.CreateGroup)
|
r.Post("/conversations/group", s.chatH.CreateGroup)
|
||||||
r.Post("/conversations/kautilya", s.chatH.OpenAIThread)
|
r.Post("/conversations/kautilya", s.chatH.OpenAIThread)
|
||||||
@@ -964,6 +965,7 @@ func (s *Server) setupRouter() {
|
|||||||
r.Delete("/messages/{messageID}/star", s.chatH.Unstar)
|
r.Delete("/messages/{messageID}/star", s.chatH.Unstar)
|
||||||
r.Get("/messages/{messageID}", s.chatH.Message)
|
r.Get("/messages/{messageID}", s.chatH.Message)
|
||||||
r.Get("/messages/{messageID}/reactions", s.chatH.MessageReactions)
|
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.Put("/messages/{messageID}/reaction", s.chatH.React)
|
||||||
r.Delete("/messages/{messageID}/reaction", s.chatH.Unreact)
|
r.Delete("/messages/{messageID}/reaction", s.chatH.Unreact)
|
||||||
r.Delete("/messages/{messageID}", s.chatH.DeleteMessage)
|
r.Delete("/messages/{messageID}", s.chatH.DeleteMessage)
|
||||||
@@ -1130,6 +1132,7 @@ func (s *Server) setupRouter() {
|
|||||||
r.Post("/chant", s.sadhnaH.SaveChant)
|
r.Post("/chant", s.sadhnaH.SaveChant)
|
||||||
r.Get("/rewards", s.sadhnaH.Rewards)
|
r.Get("/rewards", s.sadhnaH.Rewards)
|
||||||
r.Post("/rewards/ack", s.sadhnaH.AckRewards)
|
r.Post("/rewards/ack", s.sadhnaH.AckRewards)
|
||||||
|
r.Post("/rewards/gifts/{key}/open", s.sadhnaH.OpenGift)
|
||||||
if s.reminderH != nil {
|
if s.reminderH != nil {
|
||||||
r.Get("/reminders", s.reminderH.GetPrefs)
|
r.Get("/reminders", s.reminderH.GetPrefs)
|
||||||
r.Put("/reminders", s.reminderH.UpdatePrefs)
|
r.Put("/reminders", s.reminderH.UpdatePrefs)
|
||||||
@@ -1158,6 +1161,9 @@ func (s *Server) setupRouter() {
|
|||||||
r.Get("/disciples", s.adminH.ListDisciples)
|
r.Get("/disciples", s.adminH.ListDisciples)
|
||||||
|
|
||||||
// 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.Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
|
||||||
|
|
||||||
r.Get("/broadcasts", s.broadcastH.List)
|
r.Get("/broadcasts", s.broadcastH.List)
|
||||||
r.Post("/broadcasts", s.broadcastH.Create)
|
r.Post("/broadcasts", s.broadcastH.Create)
|
||||||
r.Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner)
|
r.Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner)
|
||||||
|
|||||||
@@ -418,6 +418,9 @@ type SendRequest struct {
|
|||||||
PreviewURL *string
|
PreviewURL *string
|
||||||
// Entity is something else in the app — a post — shared as a card.
|
// Entity is something else in the app — a post — shared as a card.
|
||||||
Entity *EntityAttachment
|
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
|
// Quote is a passage shared from the library, rendered as a quote the
|
||||||
// reader can tap to return to the exact phrase.
|
// reader can tap to return to the exact phrase.
|
||||||
Quote *QuoteAttachment
|
Quote *QuoteAttachment
|
||||||
@@ -458,7 +461,8 @@ func (s *ChatService) Send(ctx context.Context, userID, conversationID string, r
|
|||||||
body := strings.TrimSpace(req.Body)
|
body := strings.TrimSpace(req.Body)
|
||||||
// A photo or a shared passage with no caption is an ordinary message; only
|
// A photo or a shared passage with no caption is an ordinary message; only
|
||||||
// a message with nothing at all in it is empty.
|
// 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
|
return nil, ErrChatEmptyBody
|
||||||
}
|
}
|
||||||
if len([]rune(body)) > maxBodyRunes {
|
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
|
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 {
|
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))
|
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)
|
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.
|
// Typing broadcasts that someone is composing.
|
||||||
//
|
//
|
||||||
// Never persisted and never pushed: a typing indicator is only meaningful to
|
// Never persisted and never pushed: a typing indicator is only meaningful to
|
||||||
|
|||||||
@@ -2459,3 +2459,153 @@ func TestSend_ReplyCarriesItsQuoteImmediately(t *testing.T) {
|
|||||||
t.Error("a retried reply lost its quote")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// AckRewards clears the user's pending celebration(s) after the client has
|
||||||
// shown the confetti.
|
// 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 {
|
func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error {
|
||||||
return s.rewards.MarkCelebrated(ctx, userID)
|
return s.rewards.MarkCelebrated(ctx, userID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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'));
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user