325 lines
11 KiB
Go
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
|
|
}
|