feat: add stickers
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user