feat: add web push and sadhna tracker
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
@@ -215,6 +216,65 @@ func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// PostedDates returns the set of UTC calendar days, on or after `since`,
|
||||
// on which the user created at least one post. Used by the sādhana streak
|
||||
// to detect the "posted a quote today" discipline (and to score past days
|
||||
// of the streak) without loading whole posts. Keys are "YYYY-MM-DD".
|
||||
func (r *PostRepo) PostedDates(ctx context.Context, userID string, since time.Time) (map[string]bool, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT DISTINCT (created_at AT TIME ZONE 'UTC')::date AS d
|
||||
FROM posts
|
||||
WHERE user_id = $1 AND created_at >= $2`,
|
||||
userID, since,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("posted dates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var d time.Time
|
||||
if err := rows.Scan(&d); err != nil {
|
||||
return nil, fmt.Errorf("scan posted date: %w", err)
|
||||
}
|
||||
out[d.Format("2006-01-02")] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PostCountsByDate returns, for each UTC calendar day on or after `since`,
|
||||
// how many posts the user created that day. Keys are "YYYY-MM-DD"; days
|
||||
// with no posts are absent (so a missing key reads as zero). The sādhana
|
||||
// vine uses this both for the "posted a quote today" discipline and to
|
||||
// annotate each day's fold with the number of quotes shared.
|
||||
func (r *PostRepo) PostCountsByDate(ctx context.Context, userID string, since time.Time) (map[string]int, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT (created_at AT TIME ZONE 'UTC')::date AS d, count(*) AS n
|
||||
FROM posts
|
||||
WHERE user_id = $1 AND created_at >= $2
|
||||
GROUP BY d`,
|
||||
userID, since,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("post counts by date: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var (
|
||||
d time.Time
|
||||
n int
|
||||
)
|
||||
if err := rows.Scan(&d, &n); err != nil {
|
||||
return nil, fmt.Errorf("scan post count: %w", err)
|
||||
}
|
||||
out[d.Format("2006-01-02")] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Reshare creates a new posts row owned by `resharerID` that points at
|
||||
// the canonical original via `reshared_from_post_id`. Copies the
|
||||
// original's caption / source_url / source_title and its current images
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// PushRepo stores Web Push subscriptions (one per browser/device endpoint).
|
||||
type PushRepo struct{ pg *Postgres }
|
||||
|
||||
func NewPushRepo(pg *Postgres) *PushRepo { return &PushRepo{pg: pg} }
|
||||
|
||||
// Upsert records (or refreshes) a subscription. The endpoint is unique, so a
|
||||
// browser re-subscribing — possibly under a different user — rebinds cleanly.
|
||||
func (r *PushRepo) Upsert(ctx context.Context, userID, endpoint, p256dh, auth, userAgent string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (endpoint) DO UPDATE
|
||||
SET user_id = EXCLUDED.user_id,
|
||||
p256dh = EXCLUDED.p256dh,
|
||||
auth = EXCLUDED.auth,
|
||||
user_agent = EXCLUDED.user_agent,
|
||||
last_used_at = now()`,
|
||||
userID, endpoint, p256dh, auth, userAgent,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert push subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByUser returns the user's active subscriptions.
|
||||
func (r *PushRepo) ListByUser(ctx context.Context, userID string) ([]model.PushSubscription, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT id, user_id, endpoint, p256dh, auth
|
||||
FROM push_subscriptions WHERE user_id = $1`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list push subscriptions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []model.PushSubscription
|
||||
for rows.Next() {
|
||||
var s model.PushSubscription
|
||||
if err := rows.Scan(&s.ID, &s.UserID, &s.Endpoint, &s.P256dh, &s.Auth); err != nil {
|
||||
return nil, fmt.Errorf("scan push subscription: %w", err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteByEndpoint removes a subscription (on unsubscribe or when the push
|
||||
// service reports it gone).
|
||||
func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx,
|
||||
`DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete push subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -92,6 +93,27 @@ func (r *R2) PresignPut(ctx context.Context, key, contentType string, expiry tim
|
||||
return req.URL, fmt.Sprintf("%s/%s", r.publicBase, key), nil
|
||||
}
|
||||
|
||||
// PutObject uploads bytes to R2 server-side (used by the one-off rewards
|
||||
// import tool, where the BFF itself holds the file). Returns the public URL.
|
||||
func (r *R2) PutObject(ctx context.Context, key, contentType string, body []byte) (string, error) {
|
||||
if r == nil {
|
||||
return "", errors.New("R2 not configured")
|
||||
}
|
||||
if key == "" {
|
||||
return "", errors.New("empty key")
|
||||
}
|
||||
_, err := r.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(r.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(body),
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("put object %q: %w", key, err)
|
||||
}
|
||||
return r.PublicURL(key), nil
|
||||
}
|
||||
|
||||
// PublicURL builds the public URL for an arbitrary key. Useful when reading
|
||||
// existing user.avatar_url values back.
|
||||
func (r *R2) PublicURL(key string) string {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReminderRepo backs the sādhana reminder scheduler: per-user preferences,
|
||||
// the timezone used to fire at local time, and the candidate query the
|
||||
// worker scans each tick.
|
||||
type ReminderRepo struct{ pg *Postgres }
|
||||
|
||||
func NewReminderRepo(pg *Postgres) *ReminderRepo { return &ReminderRepo{pg: pg} }
|
||||
|
||||
// ReminderCandidate is one user the worker evaluates per tick. The time-of-day
|
||||
// windowing + per-day dedupe decision happens in Go (using TZ); the SQL just
|
||||
// supplies the inputs, including whether today's disciplines are already done
|
||||
// (bucketed by UTC day, matching the streak logic).
|
||||
type ReminderCandidate struct {
|
||||
UserID string
|
||||
Email *string
|
||||
TZ string
|
||||
ChantTime string // "HH:MM" local
|
||||
QuoteTime string // "HH:MM" local
|
||||
LastChantOn *time.Time
|
||||
LastQuoteOn *time.Time
|
||||
LastChantDate *time.Time // most recent completed chant day (UTC), nil if never
|
||||
ChantDoneToday bool
|
||||
QuoteDoneToday bool
|
||||
}
|
||||
|
||||
// SetTimezone records the user's IANA timezone (validated by the caller).
|
||||
func (r *ReminderRepo) SetTimezone(ctx context.Context, userID, tz string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `UPDATE users SET timezone = $1 WHERE id = $2`, tz, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set timezone: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEnabled returns whether the user wants sādhana reminders.
|
||||
func (r *ReminderRepo) GetEnabled(ctx context.Context, userID string) (bool, error) {
|
||||
var enabled bool
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT sadhna_reminders_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get reminder pref: %w", err)
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// SetEnabled flips the user's reminder opt-in.
|
||||
func (r *ReminderRepo) SetEnabled(ctx context.Context, userID string, enabled bool) error {
|
||||
_, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE users SET sadhna_reminders_enabled = $1 WHERE id = $2`, enabled, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set reminder pref: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DueCandidates returns reminder-enabled users who haven't already had BOTH
|
||||
// reminders today, with the data the worker needs to decide. Defaults are
|
||||
// folded in (06:00 chant, 21:30 quote, UTC tz).
|
||||
func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
COALESCE(NULLIF(u.timezone, ''), 'UTC'),
|
||||
COALESCE(NULLIF(u.preferred_chanting_time, ''), '06:00'),
|
||||
COALESCE(NULLIF(u.peak_active_time, ''), '21:30'),
|
||||
u.last_chant_reminder_on,
|
||||
u.last_quote_reminder_on,
|
||||
(SELECT max(l.log_date) FROM sadhna_logs l
|
||||
WHERE l.user_id = u.id AND l.chant_done),
|
||||
EXISTS(SELECT 1 FROM sadhna_logs l
|
||||
WHERE l.user_id = u.id AND l.chant_done
|
||||
AND l.log_date = (now() AT TIME ZONE 'UTC')::date),
|
||||
EXISTS(SELECT 1 FROM posts p
|
||||
WHERE p.user_id = u.id
|
||||
AND (p.created_at AT TIME ZONE 'UTC')::date = (now() AT TIME ZONE 'UTC')::date)
|
||||
FROM users u
|
||||
WHERE u.sadhna_reminders_enabled = TRUE
|
||||
AND (
|
||||
u.last_chant_reminder_on IS NULL OR u.last_chant_reminder_on < (now() AT TIME ZONE 'UTC')::date
|
||||
OR u.last_quote_reminder_on IS NULL OR u.last_quote_reminder_on < (now() AT TIME ZONE 'UTC')::date
|
||||
)`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("due candidates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ReminderCandidate
|
||||
for rows.Next() {
|
||||
var c ReminderCandidate
|
||||
if err := rows.Scan(
|
||||
&c.UserID, &c.Email, &c.TZ, &c.ChantTime, &c.QuoteTime,
|
||||
&c.LastChantOn, &c.LastQuoteOn, &c.LastChantDate,
|
||||
&c.ChantDoneToday, &c.QuoteDoneToday,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan candidate: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkChantReminded / MarkQuoteReminded stamp the UTC date a reminder went
|
||||
// out, so the same kind won't fire again that day.
|
||||
func (r *ReminderRepo) MarkChantReminded(ctx context.Context, userID string, utcDate time.Time) error {
|
||||
_, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE users SET last_chant_reminder_on = $1 WHERE id = $2`, utcDate, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ReminderRepo) MarkQuoteReminded(ctx context.Context, userID string, utcDate time.Time) error {
|
||||
_, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE users SET last_quote_reminder_on = $1 WHERE id = $2`, utcDate, userID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// SadhnaRepo wraps Postgres access for sadhna_logs — the chanting half of
|
||||
// a day's sādhana. The posting half lives in the posts table and is read
|
||||
// via PostRepo.PostedDates, so this repo stays small.
|
||||
type SadhnaRepo struct{ pg *Postgres }
|
||||
|
||||
func NewSadhnaRepo(pg *Postgres) *SadhnaRepo { return &SadhnaRepo{pg: pg} }
|
||||
|
||||
// UpsertToday records (or updates) the given day's chanting progress for
|
||||
// the user. The caller passes the UTC calendar date explicitly (rather
|
||||
// than relying on the DB's CURRENT_DATE) so day bucketing stays identical
|
||||
// to PostedDates' `AT TIME ZONE 'UTC'`. chantDone is decided by the caller
|
||||
// — rounds_completed >= the user's current target — and frozen into the
|
||||
// row so later target changes don't rewrite past days. Returns the row.
|
||||
func (r *SadhnaRepo) UpsertToday(ctx context.Context, userID string, logDate time.Time, rounds int, chantDone bool) (*model.SadhnaLog, error) {
|
||||
var l model.SadhnaLog
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO sadhna_logs (user_id, log_date, rounds_completed, chant_done)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, log_date) DO UPDATE
|
||||
SET rounds_completed = EXCLUDED.rounds_completed,
|
||||
chant_done = EXCLUDED.chant_done,
|
||||
updated_at = now()
|
||||
RETURNING id, user_id, log_date, rounds_completed, chant_done, created_at, updated_at`,
|
||||
userID, logDate, rounds, chantDone,
|
||||
).Scan(&l.ID, &l.UserID, &l.LogDate, &l.RoundsCompleted, &l.ChantDone, &l.CreatedAt, &l.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert sadhna log: %w", err)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// ListSince returns the user's sadhna logs on or after `since`, newest
|
||||
// first. The window bounds the streak/growth computation; a few hundred
|
||||
// rows at most.
|
||||
func (r *SadhnaRepo) ListSince(ctx context.Context, userID string, since time.Time) ([]model.SadhnaLog, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT id, user_id, log_date, rounds_completed, chant_done, created_at, updated_at
|
||||
FROM sadhna_logs
|
||||
WHERE user_id = $1 AND log_date >= $2
|
||||
ORDER BY log_date DESC`,
|
||||
userID, since,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sadhna logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []model.SadhnaLog
|
||||
for rows.Next() {
|
||||
var l model.SadhnaLog
|
||||
if err := rows.Scan(&l.ID, &l.UserID, &l.LogDate, &l.RoundsCompleted, &l.ChantDone, &l.CreatedAt, &l.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan sadhna log: %w", err)
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"_note": "Sādhana streak rewards catalog. `name` values are evocative suggestions — edit freely. Write each `meaning` (symbolic significance) yourself; blank meanings are fine until you fill them. `key` is the stable slug (R2 object key + DB key); don't rename unless you also rename the source file. `file` is the source image in this folder. `image_url` is filled automatically by the R2 upload step. `active:false` removes a reward from the gifting pool.",
|
||||
"r2_prefix": "sadhna-rewards",
|
||||
"rewards": [
|
||||
{
|
||||
"key": "conchshell",
|
||||
"file": "conchshell.png",
|
||||
"name": "Śaṅkh — The Cosmic Conch",
|
||||
"meaning": "The Divine Conchshell held by Lord Viśnu in one of his hand symbolising purity, auspiciousness, and the primordial sound 'Om'",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/conchshell.png"
|
||||
},
|
||||
{
|
||||
"key": "white-lotus",
|
||||
"file": "white-lotus.png",
|
||||
"name": "Sahasra-dala — Lotus of Pure Being",
|
||||
"meaning": "The symbol of absolute purity of mind, heart, and soul, untouched by external negativity.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/white-lotus.png"
|
||||
},
|
||||
{
|
||||
"key": "pink-lotus",
|
||||
"file": "pink-lotus.png",
|
||||
"name": "Hṛdaya-padma — Lotus of the Surrendered Heart",
|
||||
"meaning": "They symbol of Śri Herself, pink lotus symbolises the loving devotion towards the Lord",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/pink-lotus.png"
|
||||
},
|
||||
{
|
||||
"key": "golden-lotus",
|
||||
"file": "golden-lotus.png",
|
||||
"name": "Hiraṇmaya Padma — The Golden Lotus of Transcendence",
|
||||
"meaning": "The River Ganges flowing from the heavenly planets is full of these golden lotus flowers, and the residents of those planets eat the stems of the those lotuses. Thus they are very beautiful, more so than the inhabitants of any other planet.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/golden-lotus.png"
|
||||
},
|
||||
{
|
||||
"key": "peacock-feather",
|
||||
"file": "peacock-feather.png",
|
||||
"name": "Mayūra-piñcha — Crown of the Cosmic Cowherd",
|
||||
"meaning": "Bedeckded by Lord Kṛṣṇa Himself; the peacock feather is the symbol of absorbing negativity and transforming it into something divine.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/peacock-feather.png"
|
||||
},
|
||||
{
|
||||
"key": "mridangam",
|
||||
"file": "mridangam.png",
|
||||
"name": "Mṛdaṅga — Drum of the Golden Avatāra",
|
||||
"meaning": "Srila Prabhupada's favorite musical instrument. The Heart of the Sankirtan movement.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/mridangam.png"
|
||||
},
|
||||
{
|
||||
"key": "manjira",
|
||||
"file": "manjira.png",
|
||||
"name": "Karatāla — Cymbals of the Eternal Name",
|
||||
"meaning": "The hand cymbals that ring through every kīrtana. Their clear, bright sound keeps the rhythm of the Holy Name and gathers many voices into one — the heartbeat of saṅkīrtana alongside the mṛdaṅga.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/manjira.png"
|
||||
},
|
||||
{
|
||||
"key": "kadamba",
|
||||
"file": "kadamba.png",
|
||||
"name": "Kadamba — Blossom of Eternal Vṛndāvana",
|
||||
"meaning": "A flower so dear to Kṛṣṇa that when Kaliya serpent came, He jumped off from a Kadamba tree. The tree still exists in Kaliya Ghat in Vrndavan.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/kadamba.png"
|
||||
},
|
||||
{
|
||||
"key": "shalagram",
|
||||
"file": "shalagram.png",
|
||||
"name": "Śālagrāma — The Cosmos in a Stone",
|
||||
"meaning": "A self-manifested sacred stone from the Gaṇḍakī River, worshiped as the direct, non-different form of Lord Viṣṇu Himself. The Supreme Lord mercifully appears in stone so that the devotee may bathe, dress, and serve Him with their own hands.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/shalagram.png"
|
||||
},
|
||||
{
|
||||
"key": "srila-prabhupada-footprints",
|
||||
"file": "srila-prabhupada-footprints.png",
|
||||
"name": "Caraṇa-kamala — The Ācārya's Lotus Footprints",
|
||||
"meaning": "The lotus feet of the spiritual master are the boat that carries the soul across the ocean of birth and death. To hold his footprints upon one's head — to follow faithfully in his steps — is both the beginning and the perfection of devotional life.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/srila-prabhupada-footprints.png"
|
||||
},
|
||||
{
|
||||
"key": "kuliya-prasad",
|
||||
"file": "kuliya-prasad.png",
|
||||
"name": "Kuliyā Prasadi — Sri Radharaman Lal's favorite",
|
||||
"meaning": "Butter sweet in a tiny clay pot. Loved by and offered daily to Sri Radharaman Lal",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/kuliya-prasad.png"
|
||||
},
|
||||
{
|
||||
"key": "peda-prasadam",
|
||||
"file": "peda-prasadam.png",
|
||||
"name": "Amṛta-pedā — The Nectar Sweet",
|
||||
"meaning": "Vrndavana's most famous Peda prasadam. Haribol!",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/peda-prasadam.png"
|
||||
},
|
||||
{
|
||||
"key": "rasgulla",
|
||||
"file": "rasgulla.png",
|
||||
"name": "Rasa-gullā — Spheres of Divine Nectar",
|
||||
"meaning": "The only sweet Lord Jagannatha gives to pacify Goddess Lakśmi every year during Rath Yatra.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/rasgulla.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user