219 lines
7.0 KiB
Go
219 lines
7.0 KiB
Go
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
|
|
}
|