feat: add web push and sadhna tracker

This commit is contained in:
Krispire Admin
2026-06-08 15:47:44 +05:30
parent e352948fb1
commit f598770b32
31 changed files with 2267 additions and 2 deletions
+5
View File
@@ -66,3 +66,8 @@ SOKETI_APP_SECRET=
SOKETI_HOST=ws.prabhupadasays.org
SOKETI_PORT=443
SOKETI_USE_TLS=true
# Web Push
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=
+152
View File
@@ -0,0 +1,152 @@
// Command uploadrewards is a one-off admin tool that uploads the sādhana
// reward images to Cloudflare R2 and writes the resulting public URLs back
// into rewards.json (and the embedded copy the BFF seeds from).
//
// Usage (from the spsays-bff directory, with R2_* in .env):
//
// go run ./cmd/uploadrewards
// go run ./cmd/uploadrewards -json ../sadhna-rewards/rewards.json
//
// It reads each reward's `file` from the images directory, uploads it to
// `<r2_prefix>/<file>` in the configured bucket, and sets `image_url` to the
// public URL. Re-running it is safe (objects are overwritten in place).
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/joho/godotenv"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
type rewardEntry struct {
Key string `json:"key"`
File string `json:"file"`
Name string `json:"name"`
Meaning string `json:"meaning"`
Active bool `json:"active"`
ImageURL string `json:"image_url"`
}
type catalog struct {
Note string `json:"_note,omitempty"`
R2Prefix string `json:"r2_prefix"`
Rewards []rewardEntry `json:"rewards"`
}
func main() {
jsonPath := flag.String("json", "../sadhna-rewards/rewards.json", "path to rewards.json")
embedPath := flag.String("embed", "internal/repository/sadhna_rewards.json", "path to the BFF embed copy to keep in sync")
envPath := flag.String("env", ".env", "path to .env with R2_* vars")
dir := flag.String("dir", "", "images directory (default: directory of -json)")
flag.Parse()
_ = godotenv.Load(*envPath)
r2cfg := repository.R2Config{
AccountID: os.Getenv("R2_ACCOUNT_ID"),
AccessKeyID: os.Getenv("R2_ACCESS_KEY_ID"),
SecretAccessKey: os.Getenv("R2_SECRET_ACCESS_KEY"),
Bucket: os.Getenv("R2_BUCKET"),
PublicURL: os.Getenv("R2_PUBLIC_URL"),
}
r2, err := repository.NewR2(r2cfg)
if err != nil {
log.Fatalf("R2 init: %v", err)
}
if r2 == nil {
log.Fatalf("R2 not configured — set R2_* in %s", *envPath)
}
raw, err := os.ReadFile(*jsonPath)
if err != nil {
log.Fatalf("read %s: %v", *jsonPath, err)
}
var cat catalog
if err := json.Unmarshal(raw, &cat); err != nil {
log.Fatalf("parse %s: %v", *jsonPath, err)
}
imagesDir := *dir
if imagesDir == "" {
imagesDir = filepath.Dir(*jsonPath)
}
prefix := strings.Trim(cat.R2Prefix, "/")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
for i := range cat.Rewards {
rw := &cat.Rewards[i]
if rw.File == "" {
log.Printf("skip %q: no file", rw.Key)
continue
}
body, err := os.ReadFile(filepath.Join(imagesDir, rw.File))
if err != nil {
log.Fatalf("read image %s: %v", rw.File, err)
}
key := rw.File
if prefix != "" {
key = prefix + "/" + rw.File
}
url, err := r2.PutObject(ctx, key, contentType(rw.File), body)
if err != nil {
log.Fatalf("upload %s: %v", rw.File, err)
}
rw.ImageURL = url
fmt.Printf("✓ %-32s → %s\n", rw.Key, url)
}
out, err := marshalPretty(cat)
if err != nil {
log.Fatalf("encode json: %v", err)
}
if err := os.WriteFile(*jsonPath, out, 0o644); err != nil {
log.Fatalf("write %s: %v", *jsonPath, err)
}
if *embedPath != "" {
if err := os.WriteFile(*embedPath, out, 0o644); err != nil {
log.Fatalf("write embed copy %s: %v", *embedPath, err)
}
fmt.Printf("\nSynced embed copy: %s\n", *embedPath)
}
fmt.Printf("Uploaded %d reward image(s). image_url written to %s\n", len(cat.Rewards), *jsonPath)
}
func contentType(file string) string {
switch strings.ToLower(filepath.Ext(file)) {
case ".png":
return "image/png"
case ".svg":
return "image/svg+xml"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".webp":
return "image/webp"
case ".gif":
return "image/gif"
default:
return "application/octet-stream"
}
}
func marshalPretty(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false) // keep diacritics / punctuation literal
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+1
View File
@@ -3,6 +3,7 @@ module github.com/prabhupadasays/spsays-bff
go 1.25.0
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/aws/aws-sdk-go-v2 v1.41.9
github.com/aws/aws-sdk-go-v2/credentials v1.19.19
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2
+43
View File
@@ -1,6 +1,8 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
@@ -41,6 +43,7 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
@@ -107,16 +110,34 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -124,19 +145,41 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+12
View File
@@ -89,6 +89,14 @@ type Config struct {
SoketiHost string
SoketiPort int
SoketiUseTLS bool
// Web Push (VAPID). Empty keys disable push delivery (other channels
// still run). VAPIDSubject is a mailto: or https: contact URL required
// by the spec. The public key is also served to the browser to
// subscribe.
VAPIDPublicKey string
VAPIDPrivateKey string
VAPIDSubject string
}
// Load reads configuration from environment variables (with .env fallback).
@@ -154,6 +162,10 @@ func Load() (*Config, error) {
SoketiHost: getEnv("SOKETI_HOST", "ws.prabhupadasays.org"),
SoketiPort: getEnvInt("SOKETI_PORT", 443),
SoketiUseTLS: getEnvBool("SOKETI_USE_TLS", true),
VAPIDPublicKey: getEnv("VAPID_PUBLIC_KEY", ""),
VAPIDPrivateKey: getEnv("VAPID_PRIVATE_KEY", ""),
VAPIDSubject: getEnv("VAPID_SUBJECT", "mailto:admin@prabhupadasays.org"),
}
if cfg.MongoURI == "" {
+89
View File
@@ -0,0 +1,89 @@
package handler
import (
"encoding/json"
"net/http"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// PushHandler serves Web Push subscription management. The VAPID public key
// is handed to the browser so it can subscribe; subscriptions are stored per
// user. All routes require auth.
type PushHandler struct {
push *service.PushSender // optional — nil when VAPID isn't configured
repo *repository.PushRepo
}
func NewPushHandler(push *service.PushSender, repo *repository.PushRepo) *PushHandler {
return &PushHandler{push: push, repo: repo}
}
// Key — GET /push/key. Returns the VAPID public key + whether push is on.
func (h *PushHandler) Key(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusOK, struct {
PublicKey string `json:"public_key"`
Enabled bool `json:"enabled"`
}{
PublicKey: h.push.PublicKey(),
Enabled: h.push.Enabled(),
})
}
type subscribeReq struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
}
// Subscribe — POST /push/subscribe. Stores (or refreshes) the caller's push
// subscription. Body is the browser PushSubscription JSON.
func (h *PushHandler) Subscribe(w http.ResponseWriter, r *http.Request) {
if h.repo == nil || !h.push.Enabled() {
response.Error(w, http.StatusServiceUnavailable, "push not configured")
return
}
uid := middleware.UserID(r.Context())
var req subscribeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" {
response.Error(w, http.StatusBadRequest, "missing subscription fields")
return
}
if err := h.repo.Upsert(r.Context(), uid, req.Endpoint, req.Keys.P256dh, req.Keys.Auth, r.UserAgent()); err != nil {
response.Error(w, http.StatusInternalServerError, "could not save subscription")
return
}
response.NoContent(w)
}
type unsubscribeReq struct {
Endpoint string `json:"endpoint"`
}
// Unsubscribe — POST /push/unsubscribe. Removes a subscription by endpoint.
func (h *PushHandler) Unsubscribe(w http.ResponseWriter, r *http.Request) {
if h.repo == nil {
response.NoContent(w)
return
}
var req unsubscribeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Endpoint) == "" {
response.Error(w, http.StatusBadRequest, "missing endpoint")
return
}
if err := h.repo.DeleteByEndpoint(r.Context(), req.Endpoint); err != nil {
response.Error(w, http.StatusInternalServerError, "could not remove subscription")
return
}
response.NoContent(w)
}
+75
View File
@@ -0,0 +1,75 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// ReminderHandler serves the sādhana-reminder preference + the timezone the
// scheduler fires against. All routes require auth.
type ReminderHandler struct {
svc *service.ReminderService
}
func NewReminderHandler(svc *service.ReminderService) *ReminderHandler {
return &ReminderHandler{svc: svc}
}
type reminderPrefResp struct {
Enabled bool `json:"enabled"`
}
// GetPrefs — GET /sadhna/reminders. Whether reminders are on for the caller.
func (h *ReminderHandler) GetPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
enabled, err := h.svc.Enabled(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load reminder preference")
return
}
response.JSON(w, http.StatusOK, reminderPrefResp{Enabled: enabled})
}
// UpdatePrefs — PUT /sadhna/reminders { enabled }. Toggles reminders.
func (h *ReminderHandler) UpdatePrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetEnabled(r.Context(), uid, req.Enabled); err != nil {
response.Error(w, http.StatusInternalServerError, "could not update reminder preference")
return
}
response.JSON(w, http.StatusOK, reminderPrefResp{Enabled: req.Enabled})
}
// SetTimezone — POST /sadhna/timezone { timezone }. The browser reports its
// IANA zone so reminders fire at local time.
func (h *ReminderHandler) SetTimezone(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req struct {
Timezone string `json:"timezone"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetTimezone(r.Context(), uid, req.Timezone); err != nil {
if errors.Is(err, service.ErrInvalidTimezone) {
response.Error(w, http.StatusBadRequest, "invalid timezone")
return
}
response.Error(w, http.StatusInternalServerError, "could not save timezone")
return
}
response.NoContent(w)
}
+124
View File
@@ -0,0 +1,124 @@
package handler
import (
"encoding/json"
"net/http"
"time"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
const sadhnaDayFmt = "2006-01-02"
// SadhnaHandler serves the sādhana-streak dashboard and records the day's
// chanting progress. Both endpoints are auth-required; the user is read
// from the request context populated by middleware.RequireAuth.
type SadhnaHandler struct {
svc *service.SadhnaService
}
func NewSadhnaHandler(svc *service.SadhnaService) *SadhnaHandler {
return &SadhnaHandler{svc: svc}
}
// Dashboard — GET /sadhna/dashboard. Returns today's progress, the 7-day
// strip, streak counters, and plant state.
func (h *SadhnaHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
dash, err := h.svc.Dashboard(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load sadhna dashboard")
return
}
response.JSON(w, http.StatusOK, dash)
}
type saveChantReq struct {
RoundsCompleted int `json:"rounds_completed"`
}
// SaveChant — POST /sadhna/chant. Persists today's round count and returns
// the recomputed dashboard so the client updates the strip + plant in one
// round-trip.
func (h *SadhnaHandler) SaveChant(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req saveChantReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
dash, err := h.svc.SaveChant(r.Context(), uid, req.RoundsCompleted)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not save chanting progress")
return
}
response.JSON(w, http.StatusOK, dash)
}
// History — GET /sadhna/history?from=YYYY-MM-DD&to=YYYY-MM-DD. Returns the
// per-day sādhana state for the inclusive UTC date range, which the
// calendar uses to render any week or month. The range is bounded to a
// year so a malformed/huge request can't scan the whole table.
func (h *SadhnaHandler) History(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
from, err := time.ParseInLocation(sadhnaDayFmt, r.URL.Query().Get("from"), time.UTC)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid 'from' date (want YYYY-MM-DD)")
return
}
to, err := time.ParseInLocation(sadhnaDayFmt, r.URL.Query().Get("to"), time.UTC)
if err != nil {
response.Error(w, http.StatusBadRequest, "invalid 'to' date (want YYYY-MM-DD)")
return
}
if to.Before(from) {
response.Error(w, http.StatusBadRequest, "'to' must not be before 'from'")
return
}
if to.Sub(from) > 366*24*time.Hour {
response.Error(w, http.StatusBadRequest, "range too large (max 1 year)")
return
}
days, err := h.svc.History(r.Context(), uid, from, to)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load sadhna history")
return
}
response.JSON(w, http.StatusOK, days)
}
type rewardsResp struct {
Collected []model.SadhnaRewardGrant `json:"collected"`
Total int `json:"total"`
}
// Rewards — GET /sadhna/rewards. The user's collected gifts (newest first)
// plus the size of the giftable catalog, for the rewards gallery.
func (h *SadhnaHandler) Rewards(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
grants, total, err := h.svc.Rewards(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load rewards")
return
}
if grants == nil {
grants = []model.SadhnaRewardGrant{}
}
response.JSON(w, http.StatusOK, rewardsResp{Collected: grants, Total: total})
}
// AckRewards — POST /sadhna/rewards/ack. Clears pending celebrations once the
// client has shown the confetti, so a gift surfaces exactly once.
func (h *SadhnaHandler) AckRewards(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.AckRewards(r.Context(), uid); err != nil {
response.Error(w, http.StatusInternalServerError, "could not acknowledge rewards")
return
}
response.NoContent(w)
}
+97
View File
@@ -0,0 +1,97 @@
package model
import "time"
// SadhnaLog is one persisted row of the sadhna_logs table — the chanting
// half of a day's sādhana. The posting half is derived from the posts
// table at read time, so it has no column here.
type SadhnaLog struct {
ID string `json:"id"`
UserID string `json:"user_id"`
LogDate time.Time `json:"log_date"`
RoundsCompleted int `json:"rounds_completed"`
ChantDone bool `json:"chant_done"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SadhnaDay is one cell of the 7-day streak strip. `Completed` is the
// conjunction the streak counts: both the chanting and the posting were
// done that calendar day.
type SadhnaDay struct {
Date string `json:"date"` // YYYY-MM-DD (UTC)
ChantDone bool `json:"chant_done"`
QuoteDone bool `json:"quote_done"`
Completed bool `json:"completed"`
}
// VineDay is one fold of the sādhana creeper — one day of practice the
// vine has wound around its pole. The creeper grows a fresh fold for each
// day of the current streak (oldest first), plus a final in-progress fold
// for an unfinished today. Rounds/Posts annotate the fold so the devotee
// sees exactly what they offered that day.
type VineDay struct {
Date string `json:"date"` // YYYY-MM-DD (UTC)
Rounds int `json:"rounds"` // rounds chanted that day
Posts int `json:"posts"` // quotes posted that day
Completed bool `json:"completed"` // both disciplines done (a full fold)
InProgress bool `json:"in_progress"` // today, not yet complete (the growing tip)
}
// SadhnaReward is one gift item in the streak-reward catalog.
type SadhnaReward struct {
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Meaning string `json:"meaning"`
ImageURL string `json:"image_url"`
Active bool `json:"active"`
Sort int `json:"sort"`
}
// SadhnaRewardGrant is a reward a user earned at a streak milestone (a
// multiple of 7 days). The full reward is embedded for direct rendering.
type SadhnaRewardGrant struct {
Milestone int `json:"milestone"`
GrantedAt time.Time `json:"granted_at"`
Reward SadhnaReward `json:"reward"`
}
// SadhnaDashboard is the single payload the /sadhna-streak page reads. It
// folds together today's progress, the week strip, the streak counters,
// and the plant's growth state so the client renders without further
// round-trips.
type SadhnaDashboard struct {
// Today's two disciplines.
TargetRounds int `json:"target_rounds"` // user's goal (16 if unset)
RoundsCompleted int `json:"rounds_completed"` // saved progress today
ChantDone bool `json:"chant_done"` // rounds reached target
QuoteDone bool `json:"quote_done"` // posted at least one quote today
TodayCompleted bool `json:"today_completed"` // both done today
// Streak counters.
CurrentStreak int `json:"current_streak"`
LongestStreak int `json:"longest_streak"`
// Plant state. Growth is 0–100; Stage is the derived label the client
// maps to a visual; Alive is false only when growth has decayed to 0.
Growth int `json:"growth"`
Stage string `json:"stage"` // dead | sprout | seedling | sapling | young | mature
Alive bool `json:"alive"`
// The trailing 7 calendar days, oldest first, for the top strip.
Week []SadhnaDay `json:"week"`
// The creeper: one fold per day of the current streak (oldest first),
// plus a final in-progress fold for an unfinished today. Capped to the
// most recent vineMaxDays folds for payload sanity; CurrentStreak holds
// the true length.
VineDays []VineDay `json:"vine_days"`
// Rewards. NextRewardDate is the projected UTC date (YYYY-MM-DD) the next
// gift lands if the streak continues — for the calendar's gift marker.
// PendingReward is a freshly earned, not-yet-celebrated gift the client
// should celebrate now (nil when there's nothing to show).
NextRewardDate *string `json:"next_reward_date"`
PendingReward *SadhnaRewardGrant `json:"pending_reward"`
}
+9
View File
@@ -171,3 +171,12 @@ type Notification struct {
ActorHandle *string `json:"actor_handle,omitempty"`
ActorAvatar *string `json:"actor_avatar_url,omitempty"`
}
// PushSubscription is one browser/device Web Push endpoint a user granted.
type PushSubscription struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Endpoint string `json:"endpoint"`
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
}
+60
View File
@@ -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
+64
View File
@@ -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
}
+22
View File
@@ -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 {
+122
View File
@@ -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
}
+67
View File
@@ -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()
}
+218
View File
@@ -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
}
+110
View File
@@ -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"
}
]
}
+67 -1
View File
@@ -33,6 +33,7 @@ type Server struct {
// Worker lifecycle
workerCancel context.CancelFunc
workerCtx context.Context // derived in initShortener; cancelled on Close
// Handlers
bookH *handler.BookHandler
@@ -55,6 +56,9 @@ type Server struct {
profileH *handler.ProfileHandler
relationH *handler.RelationshipHandler
notificationH *handler.NotificationHandler
sadhnaH *handler.SadhnaHandler
pushH *handler.PushHandler
reminderH *handler.ReminderHandler
feedbackH *handler.FeedbackHandler
statsH *handler.StatsHandler
@@ -202,13 +206,42 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret,
cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger,
)
notifSvc := service.NewNotificationService(notifRepo, userRepo, mail, rtClient, logger, cfg.FrontendURL)
pushRepo := repository.NewPushRepo(s.pg)
pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger)
s.pushH = handler.NewPushHandler(pushSender, pushRepo)
notifSvc := service.NewNotificationService(notifRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL)
// Sādhana reminders: a background worker nudges users (email + push +
// websocket) to chant / post a quote at their local time. Shares the
// notification seam above so all three channels fire.
reminderRepo := repository.NewReminderRepo(s.pg)
reminderSvc := service.NewReminderService(reminderRepo, notifSvc, logger, cfg.FrontendURL)
s.reminderH = handler.NewReminderHandler(reminderSvc)
wctx := s.workerCtx
if wctx == nil {
wctx = context.Background()
}
worker.NewReminderWorker(reminderSvc, logger).Run(wctx)
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
s.relationH = handler.NewRelationshipHandler(graphSvc)
s.notificationH = handler.NewNotificationHandler(notifSvc)
s.postH = handler.NewPostHandler(postSvc, graphSvc)
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc)
// Sādhana streak: daily chanting log + the growing-plant state. Reads
// the chanting half from sadhna_logs and the posting half from the
// existing posts table, so it only needs its own repo plus PostRepo +
// UserRepo (for the rounds target).
sadhnaRepo := repository.NewSadhnaRepo(s.pg)
rewardRepo := repository.NewRewardRepo(s.pg)
// Sync the reward catalog from the embedded rewards.json (idempotent).
if err := rewardRepo.Seed(context.Background()); err != nil {
logger.Warn("sadhna: could not seed reward catalog", zap.Error(err))
}
sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, logger)
s.sadhnaH = handler.NewSadhnaHandler(sadhnaSvc)
// Feedback + stats — share Postgres with the rest of /api/v1 and
// the existing mailer instance.
feedbackRepo := repository.NewFeedbackRepo(s.pg)
@@ -224,6 +257,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
func (s *Server) initShortener(cfg *config.Config, logger *zap.Logger) {
ctx, cancel := context.WithCancel(context.Background())
s.workerCancel = cancel
s.workerCtx = ctx
pg, err := repository.NewPostgres(ctx, cfg.PostgresURI, logger)
if err != nil {
@@ -740,6 +774,38 @@ func (s *Server) setupRouter() {
r.Get("/unread-count", s.notificationH.UnreadCount)
r.Post("/read", s.notificationH.MarkAllRead)
})
// --- Web Push --- VAPID key + per-user subscription management.
r.Route("/push", func(r chi.Router) {
if s.pushH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/key", s.pushH.Key)
r.Post("/subscribe", s.pushH.Subscribe)
r.Post("/unsubscribe", s.pushH.Unsubscribe)
})
// --- Sādhana streak --- daily chanting log + growing-plant state.
// All endpoints require a signed-in user.
r.Route("/sadhna", func(r chi.Router) {
if s.sadhnaH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/dashboard", s.sadhnaH.Dashboard)
r.Get("/history", s.sadhnaH.History)
r.Post("/chant", s.sadhnaH.SaveChant)
r.Get("/rewards", s.sadhnaH.Rewards)
r.Post("/rewards/ack", s.sadhnaH.AckRewards)
if s.reminderH != nil {
r.Get("/reminders", s.reminderH.GetPrefs)
r.Put("/reminders", s.reminderH.UpdatePrefs)
r.Post("/timezone", s.reminderH.SetTimezone)
}
})
})
s.router = r
+96 -1
View File
@@ -29,6 +29,11 @@ import (
const (
NotifFollowRequest = "follow_request"
NotifFollowAccepted = "follow_accepted"
// Reminder kinds are delivered live (email + push + a websocket toast)
// but NOT persisted — they're ephemeral nudges, not history.
NotifChantReminder = "chant_reminder"
NotifQuoteReminder = "quote_reminder"
)
type NotificationService struct {
@@ -36,16 +41,18 @@ type NotificationService struct {
users *repository.UserRepo
mail mailer.Mailer
rt *realtime.SoketiClient // optional — nil disables WebSocket push
push *PushSender // optional — nil disables Web Push
logger *zap.Logger
frontendURL string
}
func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, logger *zap.Logger, frontendURL string) *NotificationService {
func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, push *PushSender, logger *zap.Logger, frontendURL string) *NotificationService {
return &NotificationService{
notifs: notifs,
users: users,
mail: mail,
rt: rt,
push: push,
logger: logger,
frontendURL: strings.TrimRight(frontendURL, "/"),
}
@@ -109,6 +116,10 @@ func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID
// recipient has an email; authoritative data still comes from REST.
s.publishRealtime(ctx, recipientID, kind, actorUser)
// Web Push (best-effort) — reaches the recipient even with the app
// closed. Same seam as realtime/email; nil sender is a no-op.
s.publishPush(ctx, recipientID, kind, actorUser)
// Email (best-effort).
recipient, err := s.users.FindByID(ctx, recipientID)
if err != nil {
@@ -158,6 +169,73 @@ func (s *NotificationService) publishRealtime(ctx context.Context, recipientID,
}
}
// SendReminder delivers an ephemeral sādhana reminder across all live
// channels — a websocket toast (no unread bump, not persisted), a Web Push,
// and an email. Best-effort; nothing is persisted to the notification
// center. `url` is where tapping the notification lands.
func (s *NotificationService) SendReminder(ctx context.Context, userID, kind, title, body, url string) {
// Realtime toast — a distinct "reminder" event so the client toasts
// without bumping the unread badge (reminders have no DB row).
if s.rt != nil {
_ = s.rt.Trigger(ctx, "notifications."+userID, "reminder", map[string]any{
"kind": kind, "title": title, "body": body, "url": url,
})
}
// Web Push (reaches closed apps).
if s.push != nil {
s.push.SendToUser(ctx, userID, PushPayload{Title: title, Body: body, URL: url, Tag: kind})
}
// Email (only if there's an address on file).
recipient, err := s.users.FindByID(ctx, userID)
if err != nil || recipient == nil || recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
return
}
var buf bytes.Buffer
if err := reminderEmailTpl.Execute(&buf, map[string]any{
"RecipientName": recipient.Name,
"Message": body,
"URL": url,
}); err != nil {
s.logger.Warn("render reminder email failed", zap.String("kind", kind), zap.Error(err))
return
}
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), title, buf.String()); err != nil {
s.logger.Warn("reminder email send failed", zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
}
}
// publishPush delivers a Web Push notification to the recipient's devices.
// The body quotes the actor; tapping it opens their profile (where a pending
// follow request can be accepted). Best-effort; nil sender is a no-op.
func (s *NotificationService) publishPush(ctx context.Context, recipientID, kind string, actor *model.User) {
if s.push == nil {
return
}
who := "Someone"
url := s.frontendURL + "/notifications"
if actor != nil {
who = actorDisplay(actor)
url = s.frontendURL + "/u/" + actor.Handle
}
var body string
switch kind {
case NotifFollowRequest:
body = who + " requested to follow you"
case NotifFollowAccepted:
body = who + " accepted your follow request"
default:
body = "You have a new notification"
}
s.push.SendToUser(ctx, recipientID, PushPayload{
Title: "Prabhupāda Says",
Body: body,
URL: url,
Tag: kind,
})
}
func (s *NotificationService) render(kind string, recipient, actor *model.User) (string, error) {
data := map[string]any{
"RecipientName": recipient.Name,
@@ -221,3 +299,20 @@ var followAcceptedEmailTpl = template.Must(template.New("followAccepted").Parse(
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
// Daily sādhana reminder. The Message is the recency-aware nudge; the CTA
// opens the streak page.
var reminderEmailTpl = template.Must(template.New("reminder").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p>{{.Message}}</p>
<p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open your sādhana</a>
</p>
<p style="margin-top:1.5em;font-size:14px;color:#7a6a55">You can turn these reminders off any time in Settings → Notifications.</p>
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
+98
View File
@@ -0,0 +1,98 @@
package service
import (
"context"
"encoding/json"
"net/http"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
// PushPayload is the JSON the service worker receives in its `push` event.
type PushPayload struct {
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url,omitempty"`
Icon string `json:"icon,omitempty"`
Tag string `json:"tag,omitempty"`
}
// PushSender delivers Web Push messages via VAPID. Construct with
// NewPushSender; it returns nil when VAPID isn't configured, and every method
// is nil-safe so callers needn't branch.
type PushSender struct {
repo *repository.PushRepo
pub string
priv string
subject string
logger *zap.Logger
}
// NewPushSender returns nil if the VAPID keys are absent (push disabled).
func NewPushSender(repo *repository.PushRepo, publicKey, privateKey, subject string, logger *zap.Logger) *PushSender {
if publicKey == "" || privateKey == "" || repo == nil {
return nil
}
return &PushSender{repo: repo, pub: publicKey, priv: privateKey, subject: subject, logger: logger}
}
// PublicKey is the VAPID application server key the browser subscribes with.
func (p *PushSender) PublicKey() string {
if p == nil {
return ""
}
return p.pub
}
// Enabled reports whether push delivery is configured.
func (p *PushSender) Enabled() bool { return p != nil }
// SendToUser pushes a payload to every subscription the user has, pruning any
// endpoint the push service reports as gone (404/410). Best-effort: failures
// are logged, never returned.
func (p *PushSender) SendToUser(ctx context.Context, userID string, payload PushPayload) {
if p == nil {
return
}
subs, err := p.repo.ListByUser(ctx, userID)
if err != nil {
p.logger.Warn("push: list subscriptions failed", zap.String("user", userID), zap.Error(err))
return
}
if len(subs) == 0 {
return
}
body, err := json.Marshal(payload)
if err != nil {
return
}
for _, s := range subs {
sub := &webpush.Subscription{
Endpoint: s.Endpoint,
Keys: webpush.Keys{P256dh: s.P256dh, Auth: s.Auth},
}
resp, err := webpush.SendNotification(body, sub, &webpush.Options{
Subscriber: p.subject,
VAPIDPublicKey: p.pub,
VAPIDPrivateKey: p.priv,
TTL: 60,
Urgency: webpush.UrgencyNormal,
})
if err != nil {
p.logger.Warn("push: send failed", zap.String("user", userID), zap.Error(err))
continue
}
status := resp.StatusCode
_ = resp.Body.Close()
// Subscription expired/gone — drop it so we stop trying.
if status == http.StatusNotFound || status == http.StatusGone {
if derr := p.repo.DeleteByEndpoint(ctx, s.Endpoint); derr != nil {
p.logger.Warn("push: prune failed", zap.Error(derr))
}
} else if status >= 400 {
p.logger.Warn("push: non-2xx", zap.Int("status", status), zap.String("user", userID))
}
}
}
+171
View File
@@ -0,0 +1,171 @@
// Package service — ReminderService drives the daily sādhana reminders.
//
// A worker calls RunOnce on a fixed tick. For each reminder-enabled user it
// computes the user's *local* clock time (from their IANA timezone) and, if
// it falls in the send window for that reminder AND they haven't done the
// discipline today AND they haven't already been reminded today, sends the
// nudge through every channel (email + push + websocket toast).
//
// "Done today" and the once-per-day dedupe are bucketed by UTC calendar day,
// matching the streak logic; only the *time of day* the reminder fires is
// local. The recency-aware chant message is chosen from the user's most
// recent completed chant day.
package service
import (
"context"
"errors"
"strconv"
"strings"
"time"
// Embed the IANA tz database so time.LoadLocation works regardless of
// whether the host image ships zoneinfo.
_ "time/tzdata"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
// ErrInvalidTimezone is returned when a timezone-set request carries a value
// that isn't a loadable IANA zone.
var ErrInvalidTimezone = errors.New("invalid timezone")
const (
// reminderWindowMin must be >= the worker tick so no target time is
// missed between ticks; the per-day dedupe prevents a double-send within
// the overlap.
reminderWindowMin = 20
chantMsgStart = "Chant with Prabhupāda — start building your sādhana streak."
chantMsgLapsed = "Just one hour of sādhana can save you from this duḥkhālayam aśāśvatam. Come and build your streak again."
chantMsgYesterday = "Your sādhana was wonderful yesterday! Let's continue your sādhana today as well."
quoteMsg = "Share a verse from the Vāṇī and complete today's sādhana."
)
type ReminderService struct {
repo *repository.ReminderRepo
notif *NotificationService
logger *zap.Logger
sadhnaURL string
}
func NewReminderService(repo *repository.ReminderRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService {
return &ReminderService{
repo: repo,
notif: notif,
logger: logger,
sadhnaURL: strings.TrimRight(frontendURL, "/") + "/sadhna-streak",
}
}
// Enabled / SetEnabled / SetTimezone back the settings + tz-capture endpoints.
func (s *ReminderService) Enabled(ctx context.Context, userID string) (bool, error) {
return s.repo.GetEnabled(ctx, userID)
}
func (s *ReminderService) SetEnabled(ctx context.Context, userID string, enabled bool) error {
return s.repo.SetEnabled(ctx, userID, enabled)
}
// SetTimezone validates the IANA zone before storing so a bad value can never
// break the (timezone-using) candidate scan.
func (s *ReminderService) SetTimezone(ctx context.Context, userID, tz string) error {
tz = strings.TrimSpace(tz)
if tz == "" {
tz = "UTC"
}
if _, err := time.LoadLocation(tz); err != nil {
return ErrInvalidTimezone
}
return s.repo.SetTimezone(ctx, userID, tz)
}
// RunOnce evaluates every due candidate against `now` and fires the reminders
// that are due. Errors are logged, never returned — a bad row shouldn't stop
// the rest.
func (s *ReminderService) RunOnce(ctx context.Context, now time.Time) {
candidates, err := s.repo.DueCandidates(ctx)
if err != nil {
s.logger.Warn("reminders: load candidates failed", zap.Error(err))
return
}
utc := now.UTC()
utcToday := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
for _, c := range candidates {
loc, err := time.LoadLocation(c.TZ)
if err != nil {
loc = time.UTC
}
local := now.In(loc)
localMin := local.Hour()*60 + local.Minute()
// Chant reminder.
if !c.ChantDoneToday && notRemindedToday(c.LastChantOn, utcToday) {
if t := parseHHMM(c.ChantTime); t >= 0 && withinWindow(localMin, t) {
s.notif.SendReminder(ctx, c.UserID, NotifChantReminder,
"Time for your sādhana 🙏", chantMessage(c.LastChantDate, utcToday), s.sadhnaURL)
if err := s.repo.MarkChantReminded(ctx, c.UserID, utcToday); err != nil {
s.logger.Warn("reminders: mark chant failed", zap.String("user", c.UserID), zap.Error(err))
}
}
}
// Quote reminder.
if !c.QuoteDoneToday && notRemindedToday(c.LastQuoteOn, utcToday) {
if t := parseHHMM(c.QuoteTime); t >= 0 && withinWindow(localMin, t) {
s.notif.SendReminder(ctx, c.UserID, NotifQuoteReminder,
"Share a verse from the Vāṇī", quoteMsg, s.sadhnaURL)
if err := s.repo.MarkQuoteReminded(ctx, c.UserID, utcToday); err != nil {
s.logger.Warn("reminders: mark quote failed", zap.String("user", c.UserID), zap.Error(err))
}
}
}
}
}
// chantMessage picks the recency-aware nudge from the most recent completed
// chant day (both args are UTC midnight). The candidate is, by construction,
// not done today, so the gap is >= 1 day.
func chantMessage(lastChant *time.Time, utcToday time.Time) string {
if lastChant == nil {
return chantMsgStart
}
days := int(utcToday.Sub(*lastChant).Hours() / 24)
switch {
case days <= 1:
return chantMsgYesterday // chanted yesterday
case days <= 7:
return chantMsgLapsed // within the last week, but not yesterday
default:
return chantMsgStart // nothing in over a week
}
}
// notRemindedToday is true when no reminder of this kind has gone out on the
// current UTC day.
func notRemindedToday(last *time.Time, utcToday time.Time) bool {
return last == nil || last.Before(utcToday)
}
// withinWindow reports whether localMin falls in [target, target+window),
// handling midnight wrap.
func withinWindow(localMin, target int) bool {
diff := ((localMin-target)%1440 + 1440) % 1440
return diff < reminderWindowMin
}
// parseHHMM turns "HH:MM" into minutes-of-day, or -1 if malformed.
func parseHHMM(s string) int {
parts := strings.SplitN(strings.TrimSpace(s), ":", 2)
if len(parts) != 2 {
return -1
}
h, err1 := strconv.Atoi(parts[0])
m, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return -1
}
return h*60 + m
}
+418
View File
@@ -0,0 +1,418 @@
// Package service — SadhnaService is the sādhana-streak brain. It folds a
// user's chanting logs (sadhna_logs) together with their quote posts
// (posts table) into the single dashboard the /sadhna-streak page reads:
// today's two disciplines, the trailing 7-day strip, the streak counters,
// and the growing-plant state.
//
// A day is "complete" only when BOTH daily disciplines were done that UTC
// calendar day: the target rounds were chanted AND at least one quote was
// posted. The streak counts consecutive complete days; the plant's growth
// is a 0–100 score that rises a little each complete day and falls faster
// for each missed day, eventually decaying to 0 ("dead") — and revives as
// soon as the practice resumes. The whole thing is recomputed from the
// underlying rows on every read, so there's no derived state to keep in
// sync and back-dated edits can never drift.
package service
import (
"context"
"math/rand"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
// Tunables for the streak + plant. Kept together so the growth feel is
// easy to adjust in one place.
const (
// defaultChantTarget is the fallback daily rounds when a user hasn't
// set daily_chanting_rounds on their profile (Śrīla Prabhupāda's
// minimum for initiated devotees).
defaultChantTarget = 16
// growthMax is the score at which the plant is a full mature tree.
growthMax = 100
// growthPerDay is added for each complete day (≈25 perfect days from
// seed to full tree).
growthPerDay = 4
// regressPerDay is subtracted for each missed day. Deliberately larger
// than growthPerDay so neglect undoes progress faster than practice
// builds it (a full tree withers to nothing in ~12 missed days).
regressPerDay = 9
// streakWindowDays bounds how far back the recompute walks. Comfortably
// longer than any growth/decay can persist, so the [0,100] clamp washes
// out the unknown state before the window's start.
streakWindowDays = 400
// vineMaxDays caps how many folds the creeper payload carries. The
// client only renders as many as fit the pole anyway; CurrentStreak
// still reports the true length.
vineMaxDays = 60
)
// rewardEveryDays is the streak cadence for gifts: one reward every 7
// completed days, counted from the start of the current unbroken streak
// (days 7, 14, 21, …). A broken streak restarts the count.
const rewardEveryDays = 7
// SadhnaService computes streak/plant state and records chanting progress.
type SadhnaService struct {
logs *repository.SadhnaRepo
posts *repository.PostRepo
users *repository.UserRepo
rewards *repository.RewardRepo
logger *zap.Logger
}
func NewSadhnaService(
logs *repository.SadhnaRepo,
posts *repository.PostRepo,
users *repository.UserRepo,
rewards *repository.RewardRepo,
logger *zap.Logger,
) *SadhnaService {
return &SadhnaService{logs: logs, posts: posts, users: users, rewards: rewards, logger: logger}
}
const dayFmt = "2006-01-02"
// utcToday returns today's UTC calendar date truncated to midnight, the
// anchor every day-bucket comparison uses.
func utcToday() time.Time {
now := time.Now().UTC()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
}
// SaveChant records the user's chanting progress for today and returns the
// freshly recomputed dashboard. Rounds outside [0,1008] are clamped.
// chant_done is frozen as rounds >= the user's current target.
func (s *SadhnaService) SaveChant(ctx context.Context, userID string, rounds int) (*model.SadhnaDashboard, error) {
if rounds < 0 {
rounds = 0
}
if rounds > 1008 {
rounds = 1008
}
target := s.targetFor(ctx, userID)
chantDone := rounds >= target
if _, err := s.logs.UpsertToday(ctx, userID, utcToday(), rounds, chantDone); err != nil {
return nil, err
}
return s.Dashboard(ctx, userID)
}
// Dashboard recomputes the user's full sādhana state from their chanting
// logs and quote posts over the trailing window.
func (s *SadhnaService) Dashboard(ctx context.Context, userID string) (*model.SadhnaDashboard, error) {
target := s.targetFor(ctx, userID)
today := utcToday()
since := today.AddDate(0, 0, -(streakWindowDays - 1))
logs, err := s.logs.ListSince(ctx, userID, since)
if err != nil {
return nil, err
}
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, since)
if err != nil {
return nil, err
}
// Index the chanting half by day.
chantByDay := make(map[string]bool, len(logs))
roundsByDay := make(map[string]int, len(logs))
for _, l := range logs {
key := l.LogDate.UTC().Format(dayFmt)
chantByDay[key] = l.ChantDone
roundsByDay[key] = l.RoundsCompleted
}
// posted[day] = at least one quote that day; postsByDay[day] = how many.
posted := func(day string) bool { return postsByDay[day] > 0 }
// completed[day] = both disciplines done that day.
completed := func(day string) bool { return chantByDay[day] && posted(day) }
todayKey := today.Format(dayFmt)
// --- Growth: forward-simulate the window, day by day. Today never
// regresses the plant (the day isn't over yet) — it only adds growth
// once complete. ---
growth := 0
for d := since; !d.After(today); d = d.AddDate(0, 0, 1) {
key := d.Format(dayFmt)
isToday := key == todayKey
switch {
case completed(key):
growth += growthPerDay
if growth > growthMax {
growth = growthMax
}
case isToday:
// in-progress day — leave growth as-is
default:
growth -= regressPerDay
if growth < 0 {
growth = 0
}
}
}
// --- Streaks. Current streak ends at today if today is complete,
// otherwise at yesterday (so an unfinished today doesn't read as a
// broken streak mid-day). ---
current := 0
cursor := today
if !completed(todayKey) {
cursor = today.AddDate(0, 0, -1)
}
for !cursor.Before(since) && completed(cursor.Format(dayFmt)) {
current++
cursor = cursor.AddDate(0, 0, -1)
}
// Longest run of complete days anywhere in the window.
longest, run := 0, 0
for d := since; !d.After(today); d = d.AddDate(0, 0, 1) {
if completed(d.Format(dayFmt)) {
run++
if run > longest {
longest = run
}
} else {
run = 0
}
}
if current > longest {
longest = current
}
// --- The trailing 7-day strip, oldest first. ---
week := make([]model.SadhnaDay, 0, 7)
for i := 6; i >= 0; i-- {
d := today.AddDate(0, 0, -i)
key := d.Format(dayFmt)
week = append(week, model.SadhnaDay{
Date: key,
ChantDone: chantByDay[key],
QuoteDone: posted(key),
Completed: completed(key),
})
}
// --- The creeper: one fold per day of the current streak (oldest
// first), plus a final in-progress fold for an unfinished today. The
// streak's complete days end at today when today is complete, else at
// yesterday; an unfinished today is appended as the growing tip. ---
vine := make([]model.VineDay, 0, current+1)
if current >= 1 {
end := today
if !completed(todayKey) {
end = today.AddDate(0, 0, -1)
}
start := end.AddDate(0, 0, -(current - 1))
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
key := d.Format(dayFmt)
vine = append(vine, model.VineDay{
Date: key,
Rounds: roundsByDay[key],
Posts: postsByDay[key],
Completed: true,
})
}
}
if !completed(todayKey) {
vine = append(vine, model.VineDay{
Date: todayKey,
Rounds: roundsByDay[todayKey],
Posts: postsByDay[todayKey],
InProgress: true,
})
}
if len(vine) > vineMaxDays {
vine = vine[len(vine)-vineMaxDays:]
}
// --- Rewards: a gift every 7 streak-days. When today completes a
// multiple-of-7 streak day, grant one (idempotently). Surface any
// not-yet-celebrated grant + the projected date of the next gift. ---
var pending *model.SadhnaRewardGrant
var nextReward *string
if s.rewards != nil {
todayDone := completed(todayKey)
if todayDone && current > 0 && current%rewardEveryDays == 0 {
s.ensureGrant(ctx, userID, current)
}
pending, _ = s.rewards.PendingGrant(ctx, userID)
nextReward = nextRewardDate(today, current, todayDone)
}
return &model.SadhnaDashboard{
TargetRounds: target,
RoundsCompleted: roundsByDay[todayKey],
ChantDone: chantByDay[todayKey],
QuoteDone: posted(todayKey),
TodayCompleted: completed(todayKey),
CurrentStreak: current,
LongestStreak: longest,
Growth: growth,
Stage: stageFor(growth),
Alive: growth > 0,
Week: week,
VineDays: vine,
NextRewardDate: nextReward,
PendingReward: pending,
}, nil
}
// ensureGrant idempotently awards a gift for the given streak milestone,
// picking a reward the user hasn't received yet. Once every reward has been
// collected the pool resets (avoiding an immediate back-to-back repeat) so
// the gifts cycle. Best-effort: any error leaves the milestone ungranted,
// to be retried on the next recompute.
func (s *SadhnaService) ensureGrant(ctx context.Context, userID string, milestone int) {
has, err := s.rewards.HasMilestone(ctx, userID, milestone)
if err != nil || has {
return
}
pool, err := s.rewards.ListGiftable(ctx)
if err != nil || len(pool) == 0 {
return
}
granted, err := s.rewards.GrantedRewardIDs(ctx, userID)
if err != nil {
return
}
// Prefer rewards not yet received.
cand := make([]model.SadhnaReward, 0, len(pool))
for _, r := range pool {
if !granted[r.ID] {
cand = append(cand, r)
}
}
// All collected → cycle, excluding the most recent to avoid a repeat.
if len(cand) == 0 {
last, _ := s.rewards.LastGrantedRewardID(ctx, userID)
for _, r := range pool {
if r.ID != last {
cand = append(cand, r)
}
}
if len(cand) == 0 {
cand = pool
}
}
pick := cand[rand.Intn(len(cand))]
if err := s.rewards.CreateGrant(ctx, userID, pick.ID, milestone); err != nil {
s.logger.Warn("sadhna: could not grant reward", zap.Error(err))
}
}
// nextRewardDate projects the UTC date the next gift lands if the streak
// continues unbroken — the date the streak count next reaches a multiple of
// rewardEveryDays. An unfinished today is assumed to be completed.
func nextRewardDate(today time.Time, current int, todayCompleted bool) *string {
next := (current/rewardEveryDays + 1) * rewardEveryDays // first multiple > current
days := next - current
if !todayCompleted {
// today isn't counted yet; completing it is the first of the
// remaining days, so it lands one day sooner.
days--
}
if days < 0 {
days = 0
}
d := today.AddDate(0, 0, days).Format(dayFmt)
return &d
}
// Rewards returns the user's collected gifts (newest first) and the size of
// the giftable catalog (for "N of M" framing on the client).
func (s *SadhnaService) Rewards(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, int, error) {
grants, err := s.rewards.GrantsByUser(ctx, userID)
if err != nil {
return nil, 0, err
}
pool, err := s.rewards.ListGiftable(ctx)
if err != nil {
return nil, 0, err
}
return grants, len(pool), nil
}
// AckRewards clears the user's pending celebration(s) after the client has
// shown the confetti.
func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error {
return s.rewards.MarkCelebrated(ctx, userID)
}
// History returns the per-day sādhana state for every UTC calendar day in
// the inclusive range [from, to] — what the calendar needs to render an
// arbitrary week or month. Days with no activity (including future days)
// come back as all-false. The caller is expected to bound the range.
func (s *SadhnaService) History(ctx context.Context, userID string, from, to time.Time) ([]model.SadhnaDay, error) {
logs, err := s.logs.ListSince(ctx, userID, from)
if err != nil {
return nil, err
}
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, from)
if err != nil {
return nil, err
}
chantByDay := make(map[string]bool, len(logs))
for _, l := range logs {
chantByDay[l.LogDate.UTC().Format(dayFmt)] = l.ChantDone
}
out := make([]model.SadhnaDay, 0, 42)
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
key := d.Format(dayFmt)
quote := postsByDay[key] > 0
out = append(out, model.SadhnaDay{
Date: key,
ChantDone: chantByDay[key],
QuoteDone: quote,
Completed: chantByDay[key] && quote,
})
}
return out, nil
}
// targetFor returns the user's daily rounds goal, falling back to the
// default when unset/out of range or when the user can't be loaded (the
// dashboard should still render).
func (s *SadhnaService) targetFor(ctx context.Context, userID string) int {
u, err := s.users.FindByID(ctx, userID)
if err != nil || u == nil || u.DailyChantingRounds == nil {
return defaultChantTarget
}
if r := *u.DailyChantingRounds; r >= 1 && r <= 1008 {
return r
}
return defaultChantTarget
}
// stageFor maps a growth score to the plant's visual stage. The client
// renders the matching artwork and interpolates within the stage by the
// raw growth value.
func stageFor(growth int) string {
switch {
case growth <= 0:
return "dead"
case growth <= 15:
return "sprout"
case growth <= 35:
return "seedling"
case growth <= 60:
return "sapling"
case growth <= 85:
return "young"
default:
return "mature"
}
}
+41
View File
@@ -0,0 +1,41 @@
package worker
import (
"context"
"time"
"github.com/prabhupadasays/spsays-bff/internal/service"
"go.uber.org/zap"
)
// ReminderWorker ticks every reminderInterval and asks the ReminderService to
// fire any sādhana reminders due at that moment (in each user's local time).
// The window inside the service is wider than this interval, so no target
// time slips between ticks.
type ReminderWorker struct {
svc *service.ReminderService
logger *zap.Logger
}
const reminderInterval = 15 * time.Minute
func NewReminderWorker(svc *service.ReminderService, logger *zap.Logger) *ReminderWorker {
return &ReminderWorker{svc: svc, logger: logger}
}
// Run starts the ticker loop in a goroutine; it exits when ctx is cancelled.
func (w *ReminderWorker) Run(ctx context.Context) {
go func() {
w.logger.Info("sadhna reminder worker started", zap.Duration("interval", reminderInterval))
t := time.NewTicker(reminderInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.svc.RunOnce(ctx, time.Now())
}
}
}()
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS sadhna_logs;
+30
View File
@@ -0,0 +1,30 @@
-- Sādhana streak: one row per user per day on which they chanted.
--
-- A "day complete" (what the streak + the growing-plant animation are
-- built on) is the conjunction of two daily disciplines:
-- 1. chanting today's target rounds → recorded here (chant_done)
-- 2. posting at least one quote today → derived live from the posts
-- table (a post whose created_at falls on the same calendar day)
-- so this table only needs to capture the chanting half.
--
-- log_date is a DATE (not a timestamp) so the streak rolls over at UTC
-- midnight without any timezone math, matching how the rest of the app
-- buckets "today". chant_done is stored explicitly — decided at save time
-- as rounds_completed >= the user's target THEN — so later edits to a
-- user's daily_chanting_rounds never retroactively rewrite history.
CREATE TABLE IF NOT EXISTS sadhna_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
log_date DATE NOT NULL,
rounds_completed INTEGER NOT NULL DEFAULT 0,
chant_done BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, log_date),
CHECK (rounds_completed >= 0 AND rounds_completed <= 1008)
);
-- The streak/growth computation walks a user's recent days newest-first;
-- this serves both that scan and the "today" lookup.
CREATE INDEX IF NOT EXISTS sadhna_logs_user_date_idx
ON sadhna_logs (user_id, log_date DESC);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS sadhna_reward_grants;
DROP TABLE IF EXISTS sadhna_rewards;
@@ -0,0 +1,36 @@
-- Sādhana streak rewards.
--
-- `sadhna_rewards` is the catalog of gift items (one row per reward),
-- seeded/synced at BFF startup from the embedded rewards.json so the
-- name / meaning / image_url stay in lockstep with the source of truth.
--
-- `sadhna_reward_grants` records which reward a user earned at which
-- streak milestone (a multiple of 7 days). The UNIQUE (user_id, milestone)
-- makes granting idempotent — a milestone hands out exactly one gift no
-- matter how often the dashboard is recomputed. `celebrated_at` is NULL
-- until the client has shown the confetti celebration, so a freshly earned
-- gift surfaces exactly once.
CREATE TABLE IF NOT EXISTS sadhna_rewards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
meaning TEXT NOT NULL DEFAULT '',
image_url TEXT NOT NULL DEFAULT '',
active BOOLEAN NOT NULL DEFAULT TRUE,
sort INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sadhna_reward_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reward_id UUID NOT NULL REFERENCES sadhna_rewards(id) ON DELETE CASCADE,
milestone INTEGER NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
celebrated_at TIMESTAMPTZ,
UNIQUE (user_id, milestone)
);
CREATE INDEX IF NOT EXISTS sadhna_reward_grants_user_idx
ON sadhna_reward_grants (user_id, granted_at DESC);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS push_subscriptions;
@@ -0,0 +1,18 @@
-- Web Push subscriptions. One row per browser/device push endpoint a user
-- has granted. The endpoint is globally unique (it's a per-subscription URL
-- at the push service), so re-subscribing the same browser upserts. p256dh
-- and auth are the subscription's encryption keys (base64url). Dead
-- endpoints (404/410 from the push service) are pruned on send.
CREATE TABLE IF NOT EXISTS push_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS push_subscriptions_user_idx
ON push_subscriptions (user_id);
@@ -0,0 +1,4 @@
ALTER TABLE users DROP COLUMN IF EXISTS last_quote_reminder_on;
ALTER TABLE users DROP COLUMN IF EXISTS last_chant_reminder_on;
ALTER TABLE users DROP COLUMN IF EXISTS sadhna_reminders_enabled;
ALTER TABLE users DROP COLUMN IF EXISTS timezone;
@@ -0,0 +1,14 @@
-- Sādhana reminder support.
--
-- timezone: the user's IANA zone (e.g. "Asia/Kolkata"), reported by the
-- browser, so reminders fire at their local clock time. Defaults to UTC
-- until the client reports a real zone.
--
-- sadhna_reminders_enabled: per-user opt-out for the daily reminders.
--
-- last_{chant,quote}_reminder_on: the UTC date a reminder of that kind was
-- last sent, so each kind goes out at most once per (UTC) day.
ALTER TABLE users ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC';
ALTER TABLE users ADD COLUMN IF NOT EXISTS sadhna_reminders_enabled BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_chant_reminder_on DATE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_quote_reminder_on DATE;