feat: add stickers

This commit is contained in:
Krispire Admin
2026-08-30 02:18:53 +05:30
parent cee2d3c874
commit fecb9bc045
15 changed files with 752 additions and 6 deletions
+68 -4
View File
@@ -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
}