Files
spsays-bff/internal/repository/sadhna_reward_repo.go
T

325 lines
11 KiB
Go

package repository
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
// file dependency at runtime.
//
//go:embed sadhna_rewards.json
var rewardsSeedJSON []byte
// RewardRepo wraps Postgres access for the sādhana reward catalog and the
// per-user grants.
type RewardRepo struct{ pg *Postgres }
func NewRewardRepo(pg *Postgres) *RewardRepo { return &RewardRepo{pg: pg} }
type rewardsSeedFile struct {
Rewards []struct {
Key string `json:"key"`
Name string `json:"name"`
Meaning string `json:"meaning"`
ImageURL string `json:"image_url"`
Active bool `json:"active"`
} `json:"rewards"`
}
// Seed upserts the catalog from the embedded rewards.json by key, so editing
// a name/meaning or filling an image_url and redeploying keeps the DB in
// sync. Sort follows the file order. Idempotent.
func (r *RewardRepo) Seed(ctx context.Context) error {
var f rewardsSeedFile
if err := json.Unmarshal(rewardsSeedJSON, &f); err != nil {
return fmt.Errorf("parse rewards seed: %w", err)
}
for i, rw := range f.Rewards {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO sadhna_rewards (key, name, meaning, image_url, active, sort)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (key) DO UPDATE
SET name = EXCLUDED.name,
meaning = EXCLUDED.meaning,
image_url = EXCLUDED.image_url,
active = EXCLUDED.active,
sort = EXCLUDED.sort,
updated_at = now()`,
rw.Key, rw.Name, rw.Meaning, rw.ImageURL, rw.Active, i,
)
if err != nil {
return fmt.Errorf("seed reward %q: %w", rw.Key, err)
}
}
return nil
}
// ListGiftable returns active rewards that have an image (so a celebration
// always has something to show), ordered by sort. This is the gifting pool.
func (r *RewardRepo) ListGiftable(ctx context.Context) ([]model.SadhnaReward, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, key, name, meaning, image_url, active, sort
FROM sadhna_rewards
WHERE active = TRUE AND image_url <> ''
ORDER BY sort`)
if err != nil {
return nil, fmt.Errorf("list giftable rewards: %w", err)
}
defer rows.Close()
var out []model.SadhnaReward
for rows.Next() {
var rw model.SadhnaReward
if err := rows.Scan(&rw.ID, &rw.Key, &rw.Name, &rw.Meaning, &rw.ImageURL, &rw.Active, &rw.Sort); err != nil {
return nil, fmt.Errorf("scan reward: %w", err)
}
out = append(out, rw)
}
return out, rows.Err()
}
// GrantsByUser returns the user's earned rewards, newest first, with the
// 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, 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
-- 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 {
return nil, fmt.Errorf("grants by user: %w", err)
}
defer rows.Close()
var out []model.SadhnaRewardGrant
for rows.Next() {
var g model.SadhnaRewardGrant
if err := rows.Scan(
&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 {
return nil, fmt.Errorf("scan grant: %w", err)
}
out = append(out, g)
}
return out, rows.Err()
}
// GrantedRewardIDs returns the set of reward ids the user has already
// received, for avoiding repeats when picking the next gift.
func (r *RewardRepo) GrantedRewardIDs(ctx context.Context, userID string) (map[string]bool, error) {
rows, err := r.pg.Pool.Query(ctx,
`SELECT reward_id FROM sadhna_reward_grants WHERE user_id = $1`, userID)
if err != nil {
return nil, fmt.Errorf("granted reward ids: %w", err)
}
defer rows.Close()
out := make(map[string]bool)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan reward id: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
// LastGrantedRewardID returns the most recently granted reward id (or "" if
// none), used to avoid a back-to-back repeat once the pool has cycled.
func (r *RewardRepo) LastGrantedRewardID(ctx context.Context, userID string) (string, error) {
var id string
err := r.pg.Pool.QueryRow(ctx, `
SELECT reward_id FROM sadhna_reward_grants
WHERE user_id = $1 ORDER BY granted_at DESC, milestone DESC LIMIT 1`,
userID,
).Scan(&id)
if err != nil {
return "", nil // no rows → empty
}
return id, nil
}
// HasMilestone reports whether the user already has a grant for the given
// streak milestone (the idempotency guard).
func (r *RewardRepo) HasMilestone(ctx context.Context, userID string, milestone int) (bool, error) {
var n int
err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM sadhna_reward_grants WHERE user_id = $1 AND milestone = $2`,
userID, milestone,
).Scan(&n)
if err != nil {
return false, fmt.Errorf("has milestone: %w", err)
}
return n > 0, nil
}
// CreateGrant records a gift for a milestone. The unique (user_id, milestone)
// makes it idempotent under races; a conflict is a no-op, not an error.
func (r *RewardRepo) CreateGrant(ctx context.Context, userID, rewardID string, milestone int) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO sadhna_reward_grants (user_id, reward_id, milestone)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, milestone) DO NOTHING`,
userID, rewardID, milestone,
)
if err != nil {
return fmt.Errorf("create grant: %w", err)
}
return nil
}
// CreateCustom adds a reward to the catalog from the admin panel.
//
// Custom entries survive re-seeding: Seed upserts only the keys in its
// embedded JSON, and these keys are namespaced away from those.
func (r *RewardRepo) CreateCustom(ctx context.Context, key, name, meaning, imageURL string) (*model.SadhnaReward, error) {
var w model.SadhnaReward
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO sadhna_rewards (key, name, meaning, image_url, active, sort)
-- Sorted after the seeded catalog so custom gifts don't reshuffle it.
VALUES ($1, $2, $3, $4, true, 1000)
RETURNING id, key, name, meaning, image_url, active, sort`,
key, name, meaning, imageURL,
).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort)
if err != nil {
return nil, fmt.Errorf("create custom reward: %w", err)
}
return &w, nil
}
// DeleteCustom removes an admin-made reward from the catalog.
//
// Both sadhna_reward_grants.reward_id and saved_stickers.reward_id are
// ON DELETE CASCADE, so this also takes it out of every devotee's gallery and
// sticker vault. Stickers already SENT in a chat are unaffected: those copy
// the artwork onto the message rather than referencing the catalog, so history
// stays intact.
//
// Restricted to custom keys. A seeded reward would come straight back on the
// next Seed run, and deleting one would rip a streak reward out of the
// galleries of everyone who earned it.
func (r *RewardRepo) DeleteCustom(ctx context.Context, key string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM sadhna_rewards WHERE key = $1 AND key LIKE 'custom\_%'`, key)
if err != nil {
return fmt.Errorf("delete custom reward: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRewardNotFound
}
return nil
}
// ByKey looks up one reward from the catalog.
func (r *RewardRepo) ByKey(ctx context.Context, key string) (*model.SadhnaReward, error) {
var w model.SadhnaReward
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, `
SELECT g.milestone, g.granted_at,
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 AND g.source = 'streak'
ORDER BY g.granted_at DESC, g.milestone DESC
LIMIT 1`,
userID,
).Scan(
&g.Milestone, &g.GrantedAt,
&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
}
return &g, nil
}
// MarkCelebrated clears all of the user's pending celebrations.
func (r *RewardRepo) MarkCelebrated(ctx context.Context, userID string) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE sadhna_reward_grants SET celebrated_at = $1
WHERE user_id = $2 AND celebrated_at IS NULL`,
time.Now().UTC(), userID,
)
if err != nil {
return fmt.Errorf("mark celebrated: %w", err)
}
return nil
}