feat: add web push and sadhna tracker
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user