package repository import ( "context" _ "embed" "encoding/json" "fmt" "time" "github.com/prabhupadasays/spsays-bff/internal/model" ) // 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, 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`, 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.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 } // PendingGrant returns the most recent grant the client hasn't celebrated // yet (celebrated_at IS NULL), or nil. Drives the one-time confetti. 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 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, ) 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 }