16 Commits
46 changed files with 3638 additions and 24 deletions
+14 -1
View File
@@ -57,4 +57,17 @@ FRONTEND_URL=http://localhost:3000
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
FACEBOOK_OAUTH_CLIENT_ID=
FACEBOOK_OAUTH_CLIENT_SECRET=
FACEBOOK_OAUTH_CLIENT_SECRET=
# SOKETI
SOKETI_APP_ID=
SOKETI_APP_KEY=
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=
+33 -7
View File
@@ -12,8 +12,39 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/config"
"github.com/prabhupadasays/spsays-bff/internal/server"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// newLogger builds the app logger, routing logs by level: Debug/Info/Warn go
// to stdout and only Error/Fatal/Panic go to stderr. Hosting platforms tag
// every stderr line as "error", so without this split a routine Debug/Info
// line shows up with a misleading error badge. Production uses JSON at Info+;
// development uses colored console at Debug+.
func newLogger(production bool) *zap.Logger {
var encoder zapcore.Encoder
minLevel := zapcore.DebugLevel
if production {
encoder = zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig())
minLevel = zapcore.InfoLevel
} else {
encCfg := zap.NewDevelopmentEncoderConfig()
encCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder
encoder = zapcore.NewConsoleEncoder(encCfg)
}
low := zap.LevelEnablerFunc(func(l zapcore.Level) bool {
return l >= minLevel && l < zapcore.ErrorLevel
})
high := zap.LevelEnablerFunc(func(l zapcore.Level) bool {
return l >= zapcore.ErrorLevel
})
core := zapcore.NewTee(
zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), low),
zapcore.NewCore(encoder, zapcore.Lock(os.Stderr), high),
)
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
}
func main() {
// Load config
cfg, err := config.Load()
@@ -22,13 +53,8 @@ func main() {
os.Exit(1)
}
// Initialize logger
var logger *zap.Logger
if cfg.Env == "production" {
logger, _ = zap.NewProduction()
} else {
logger, _ = zap.NewDevelopment()
}
// Initialize logger — routes normal logs to stdout, errors to stderr.
logger := newLogger(cfg.Env == "production")
defer logger.Sync()
sugar := logger.Sugar()
+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=
+31
View File
@@ -77,6 +77,26 @@ type Config struct {
GoogleOAuthClientSecret string
FacebookOAuthClientID string
FacebookOAuthClientSecret string
// Soketi (Pusher-compatible WebSocket server) — used to push realtime
// notifications. Empty App credentials disable realtime delivery (the
// DB row + email still happen). SoketiHost is the HTTP-API origin the
// BFF triggers events against (e.g. "ws.prabhupadasays.org"); the
// frontend connects to the same host over WSS.
SoketiAppID string
SoketiAppKey string
SoketiAppSecret string
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).
@@ -135,6 +155,17 @@ func Load() (*Config, error) {
GoogleOAuthClientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET", ""),
FacebookOAuthClientID: getEnv("FACEBOOK_OAUTH_CLIENT_ID", ""),
FacebookOAuthClientSecret: getEnv("FACEBOOK_OAUTH_CLIENT_SECRET", ""),
SoketiAppID: getEnv("SOKETI_APP_ID", ""),
SoketiAppKey: getEnv("SOKETI_APP_KEY", ""),
SoketiAppSecret: getEnv("SOKETI_APP_SECRET", ""),
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 == "" {
+134
View File
@@ -0,0 +1,134 @@
package handler
import (
"encoding/json"
"net/http"
"net/url"
"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"
)
// Subscription limits. The endpoint length cap + host allow-list guard against
// junk/abusive rows and SSRF (the BFF POSTs to the endpoint when sending), and
// the per-user device cap bounds fan-out.
const (
maxEndpointLen = 2048
maxKeyLen = 256
maxDevicesPerUser = 10
)
// validatePushEndpoint requires an https URL belonging to a known browser push
// service — preventing the endpoint from pointing at internal/arbitrary hosts
// (SSRF) and keeping the table free of junk.
func validatePushEndpoint(endpoint string) bool {
if l := len(endpoint); l == 0 || l > maxEndpointLen {
return false
}
u, err := url.Parse(endpoint)
if err != nil || u.Scheme != "https" || u.Host == "" {
return false
}
host := strings.ToLower(u.Hostname())
switch host {
case "fcm.googleapis.com", "updates.push.services.mozilla.com":
return true // Chrome/Brave/Edge (FCM), Firefox
}
// Safari/iOS (*.push.apple.com) and Windows/Edge WNS (*.notify.windows.com).
for _, suffix := range []string{".push.apple.com", ".notify.windows.com", ".wns.windows.com"} {
if strings.HasSuffix(host, suffix) {
return true
}
}
return false
}
// 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 !validatePushEndpoint(req.Endpoint) {
response.Error(w, http.StatusBadRequest, "invalid push endpoint")
return
}
if len(req.Keys.P256dh) > maxKeyLen || len(req.Keys.Auth) > maxKeyLen {
response.Error(w, http.StatusBadRequest, "subscription keys too long")
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
}
// Keep only the most-recent N devices per user (best-effort).
_ = h.repo.PruneToMostRecent(r.Context(), uid, maxDevicesPerUser)
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)
}
+96 -2
View File
@@ -15,6 +15,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
@@ -124,6 +125,51 @@ func (h *PostHandler) Get(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusOK, p)
}
// likeResp is returned by Like / Unlike — the new viewer state + effective
// total (likes_count + pending Redis delta) so the client can reconcile.
type likeResp struct {
Liked bool `json:"liked"`
LikesCount int `json:"likes_count"`
}
// Like — POST /posts/{id}/like. Idempotent. RequireAuth.
func (h *PostHandler) Like(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
count, err := h.svc.Like(r.Context(), uid, id)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, likeResp{Liked: true, LikesCount: count})
}
// Unlike — DELETE /posts/{id}/like. Idempotent. RequireAuth.
func (h *PostHandler) Unlike(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
count, err := h.svc.Unlike(r.Context(), uid, id)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, likeResp{Liked: false, LikesCount: count})
}
// ListLikes — GET /posts/{id}/likes?limit=&offset=&q=. Paginated "liked by"
// users with optional name/handle search. Public (OptionalAuth).
func (h *PostHandler) ListLikes(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
limit, offset := parseLimitOffset(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
users, total, err := h.svc.Likers(r.Context(), id, q, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
}
func (h *PostHandler) Delete(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id"), uid); err != nil {
@@ -442,10 +488,11 @@ type ProfileHandler struct {
posts *repository.PostRepo
collections *repository.CollectionRepo
graph *service.SocialGraphService
likes *repository.LikeRepo
}
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, graph *service.SocialGraphService) *ProfileHandler {
return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph}
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, graph *service.SocialGraphService, likes *repository.LikeRepo) *ProfileHandler {
return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph, likes: likes}
}
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
@@ -526,9 +573,56 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
}
}
}
// Like counts (effective) + viewer-liked state for every post in the page.
if len(posts) > 0 {
ptrs := make([]*model.Post, len(posts))
for i := range posts {
ptrs[i] = &posts[i]
}
if err := h.likes.Hydrate(r.Context(), viewerID, ptrs); err != nil {
mapSocialErr(w, err)
return
}
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
// followListResp is the payload for the followers / following list endpoints:
// the page of users plus the total count (so the client can paginate). Total
// rides in `data` rather than meta because the frontend's fetch wrapper only
// surfaces `data`.
type followListResp struct {
Users []model.UserSummary `json:"users"`
Total int `json:"total"`
}
// Followers — GET /u/{handle}/followers?limit=&offset=&q=. Paginated, optional
// name/handle search. Public (OptionalAuth).
func (h *ProfileHandler) Followers(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
limit, offset := parseLimitOffset(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
users, total, err := h.graph.Followers(r.Context(), handle, q, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
}
// Following — GET /u/{handle}/following?limit=&offset=&q=.
func (h *ProfileHandler) Following(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
limit, offset := parseLimitOffset(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
users, total, err := h.graph.Following(r.Context(), handle, q, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
}
// ---------------------------------------------------------------------------
// shared helpers
// ---------------------------------------------------------------------------
+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"`
}
+23
View File
@@ -39,6 +39,13 @@ type Post struct {
// after the repo returns posts; always false for anonymous viewers.
// Drives the post-card "Reshare ↔ Send" swap on the frontend.
ViewerHasReshared bool `json:"viewer_has_reshared"`
// Likes. LikesCount is the effective total = the denormalized
// posts.likes_count plus any still-pending Redis delta (hydrated by the
// like layer). ViewerHasLiked reflects the calling user's membership row;
// false for anonymous viewers.
LikesCount int `json:"likes_count"`
ViewerHasLiked bool `json:"viewer_has_liked"`
}
// PostImage represents one attached image on a post.
@@ -167,7 +174,23 @@ type Notification struct {
CreatedAt time.Time `json:"created_at"`
ReadAt *time.Time `json:"read_at,omitempty"`
// Aggregation: for post_like, PostID is the liked post and ActorCount is
// how many likers have aggregated into this single row (the frontend
// renders "X and N-1 others liked your post"). Other kinds: PostID nil,
// ActorCount 1.
PostID *string `json:"post_id,omitempty"`
ActorCount int `json:"actor_count"`
ActorName *string `json:"actor_name,omitempty"`
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"`
}
+8
View File
@@ -12,6 +12,14 @@ import (
// pointer because OAuth-only users have no password — *PasswordHash == nil
// means "this account can only sign in via a linked OAuth provider".
//
// UserSummary is the lightweight projection used in follower / following
// lists — just enough to render a row and link to the profile.
type UserSummary struct {
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
}
// EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null`
// when the user has not yet verified, instead of an omitted field — the
// frontend uses this to decide whether to show the "verify your email" ribbon.
+305
View File
@@ -0,0 +1,305 @@
package repository
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"go.uber.org/zap"
)
// likeDeltaPrefix namespaces the per-post pending-like counters in Redis. Each
// like/unlike INCRBY/DECRBYs likes:delta:{postID}; the LikesWorker folds these
// into posts.likes_count every 15 minutes (GETDEL + UPDATE). Reads add the
// still-pending delta on top of likes_count so the displayed total is current.
const likeDeltaPrefix = "likes:delta:"
// LikeRepo handles post-like membership (Postgres) and the write-behind count
// delta (Redis). Membership is written synchronously so viewer state and the
// "liked by" list stay consistent; only the hot counter is batched.
//
// `redis` is optional: when nil (Redis unavailable) likes still work, but the
// count is applied to posts.likes_count directly on each like instead of being
// batched.
type LikeRepo struct {
pg *Postgres
redis *Redis
logger *zap.Logger
}
func NewLikeRepo(pg *Postgres, redis *Redis, logger *zap.Logger) *LikeRepo {
return &LikeRepo{pg: pg, redis: redis, logger: logger}
}
func (r *LikeRepo) deltaKey(postID string) string { return likeDeltaPrefix + postID }
// Like records a membership row. Returns whether it was newly liked (false if
// the user had already liked it — idempotent). On a real change it nudges the
// pending count up.
func (r *LikeRepo) Like(ctx context.Context, postID, userID string) (bool, error) {
tag, err := r.pg.Pool.Exec(ctx,
`INSERT INTO post_likes (post_id, user_id) VALUES ($1, $2)
ON CONFLICT (post_id, user_id) DO NOTHING`,
postID, userID,
)
if err != nil {
return false, fmt.Errorf("insert like: %w", err)
}
if tag.RowsAffected() == 0 {
return false, nil // already liked
}
r.bumpDelta(ctx, postID, 1)
return true, nil
}
// Unlike removes the membership row. Returns whether it actually changed.
func (r *LikeRepo) Unlike(ctx context.Context, postID, userID string) (bool, error) {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM post_likes WHERE post_id = $1 AND user_id = $2`,
postID, userID,
)
if err != nil {
return false, fmt.Errorf("delete like: %w", err)
}
if tag.RowsAffected() == 0 {
return false, nil // wasn't liked
}
r.bumpDelta(ctx, postID, -1)
return true, nil
}
// bumpDelta adjusts the pending count. Prefers the Redis write-behind counter;
// falls back to a direct DB update when Redis is unavailable so the count is
// never lost.
func (r *LikeRepo) bumpDelta(ctx context.Context, postID string, d int64) {
if r.redis != nil {
if err := r.redis.Client.IncrBy(ctx, r.deltaKey(postID), d).Err(); err == nil {
return
}
r.logger.Warn("likes: redis incr failed, applying to DB", zap.String("post", postID))
}
if _, err := r.pg.Pool.Exec(ctx,
`UPDATE posts SET likes_count = GREATEST(0, likes_count + $2) WHERE id = $1`,
postID, d,
); err != nil {
r.logger.Warn("likes: direct count update failed", zap.String("post", postID), zap.Error(err))
}
}
// PostAuthor returns the user id that owns the post (for like notifications).
func (r *LikeRepo) PostAuthor(ctx context.Context, postID string) (string, error) {
var uid string
if err := r.pg.Pool.QueryRow(ctx,
`SELECT user_id FROM posts WHERE id = $1`, postID,
).Scan(&uid); err != nil {
return "", fmt.Errorf("post author: %w", err)
}
return uid, nil
}
// EffectiveCount returns likes_count + the pending Redis delta for one post.
func (r *LikeRepo) EffectiveCount(ctx context.Context, postID string) (int, error) {
var c int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT likes_count FROM posts WHERE id = $1`, postID,
).Scan(&c); err != nil {
return 0, fmt.Errorf("read likes_count: %w", err)
}
if r.redis != nil {
if d, err := r.redis.Client.Get(ctx, r.deltaKey(postID)).Int(); err == nil {
c += d
}
}
if c < 0 {
c = 0
}
return c, nil
}
// Hydrate fills LikesCount (effective) and ViewerHasLiked on the given posts.
// Used by every post read path. viewerID "" leaves ViewerHasLiked false.
func (r *LikeRepo) Hydrate(ctx context.Context, viewerID string, posts []*model.Post) error {
ids := make([]string, 0, len(posts))
for _, p := range posts {
if p != nil {
ids = append(ids, p.ID)
}
}
if len(ids) == 0 {
return nil
}
// Denormalized counts from Postgres.
counts := make(map[string]int, len(ids))
rows, err := r.pg.Pool.Query(ctx,
`SELECT id, likes_count FROM posts WHERE id = ANY($1)`, ids)
if err != nil {
return fmt.Errorf("hydrate like counts: %w", err)
}
for rows.Next() {
var id string
var c int
if err := rows.Scan(&id, &c); err != nil {
rows.Close()
return fmt.Errorf("scan like count: %w", err)
}
counts[id] = c
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
// Pending deltas from Redis (best-effort).
deltas := r.pendingDeltas(ctx, ids)
// Viewer membership.
liked := make(map[string]bool)
if viewerID != "" {
lr, err := r.pg.Pool.Query(ctx,
`SELECT post_id FROM post_likes WHERE user_id = $1 AND post_id = ANY($2)`,
viewerID, ids)
if err != nil {
return fmt.Errorf("hydrate viewer likes: %w", err)
}
for lr.Next() {
var pid string
if err := lr.Scan(&pid); err != nil {
lr.Close()
return fmt.Errorf("scan viewer like: %w", err)
}
liked[pid] = true
}
lr.Close()
if err := lr.Err(); err != nil {
return err
}
}
for _, p := range posts {
if p == nil {
continue
}
c := counts[p.ID] + deltas[p.ID]
if c < 0 {
c = 0
}
p.LikesCount = c
p.ViewerHasLiked = liked[p.ID]
}
return nil
}
// pendingDeltas returns the Redis like-deltas for the given posts (absent →
// zero). Best-effort: Redis errors yield an empty map.
func (r *LikeRepo) pendingDeltas(ctx context.Context, ids []string) map[string]int {
out := make(map[string]int)
if r.redis == nil || len(ids) == 0 {
return out
}
keys := make([]string, len(ids))
for i, id := range ids {
keys[i] = r.deltaKey(id)
}
vals, err := r.redis.Client.MGet(ctx, keys...).Result()
if err != nil {
return out
}
for i, v := range vals {
s, ok := v.(string)
if !ok {
continue
}
if n, err := strconv.Atoi(s); err == nil && n != 0 {
out[ids[i]] = n
}
}
return out
}
// ListLikers returns one page of users who liked the post (newest first),
// optionally filtered by a name/handle search, plus the total matching count.
func (r *LikeRepo) ListLikers(ctx context.Context, postID, q string, limit, offset int) ([]model.UserSummary, int, error) {
like := "%" + q + "%"
var total int
if err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*)
FROM post_likes l JOIN users u ON u.id = l.user_id
WHERE l.post_id = $1
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)`,
postID, q, like,
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count likers: %w", err)
}
if total == 0 {
return []model.UserSummary{}, 0, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT u.handle, u.name, u.avatar_url
FROM post_likes l JOIN users u ON u.id = l.user_id
WHERE l.post_id = $1
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
ORDER BY l.created_at DESC, u.handle ASC
LIMIT $4 OFFSET $5`,
postID, q, like, limit, offset,
)
if err != nil {
return nil, 0, fmt.Errorf("list likers: %w", err)
}
defer rows.Close()
out := make([]model.UserSummary, 0, limit)
for rows.Next() {
var s model.UserSummary
if err := rows.Scan(&s.Handle, &s.Name, &s.AvatarURL); err != nil {
return nil, 0, fmt.Errorf("scan liker: %w", err)
}
out = append(out, s)
}
return out, total, rows.Err()
}
// FlushDeltas drains every pending Redis like-delta into posts.likes_count.
// Called periodically by the LikesWorker. Returns how many posts were updated.
// GETDEL makes each pop atomic; a failed UPDATE restores the delta so it's
// retried next cycle.
func (r *LikeRepo) FlushDeltas(ctx context.Context) (int, error) {
if r.redis == nil {
return 0, nil
}
var cursor uint64
applied := 0
for {
keys, next, err := r.redis.Client.Scan(ctx, cursor, likeDeltaPrefix+"*", 200).Result()
if err != nil {
return applied, fmt.Errorf("scan like deltas: %w", err)
}
for _, key := range keys {
postID := strings.TrimPrefix(key, likeDeltaPrefix)
v, err := r.redis.Client.GetDel(ctx, key).Int()
if err != nil || v == 0 {
continue
}
if _, err := r.pg.Pool.Exec(ctx,
`UPDATE posts SET likes_count = GREATEST(0, likes_count + $2) WHERE id = $1`,
postID, v,
); err != nil {
// Restore so the delta isn't lost; retried next cycle.
r.redis.Client.IncrBy(ctx, key, int64(v))
r.logger.Warn("likes flush: update failed, delta restored",
zap.String("post", postID), zap.Error(err))
continue
}
applied++
}
cursor = next
if cursor == 0 {
break
}
}
return applied, nil
}
+64
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,69 @@ 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 calendar day on or after `since`, how
// many posts the user created that day — bucketed in the given IANA timezone
// (`tz`) so the day boundary matches the user's local midnight. Keys are
// "YYYY-MM-DD"; days with no posts are absent (so a missing key reads as
// zero). The sādhana page uses this 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, tz string) (map[string]int, error) {
if tz == "" {
tz = "UTC"
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT (created_at AT TIME ZONE $3::text)::date AS d, count(*) AS n
FROM posts
WHERE user_id = $1 AND created_at >= $2
GROUP BY d`,
userID, since, tz,
)
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
+86
View File
@@ -0,0 +1,86 @@
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()
}
// PruneToMostRecent keeps only the `keep` most-recently-used subscriptions for
// a user, deleting older ones — a soft per-user device cap.
func (r *PushRepo) PruneToMostRecent(ctx context.Context, userID string, keep int) error {
if keep < 1 {
keep = 1
}
_, err := r.pg.Pool.Exec(ctx, `
DELETE FROM push_subscriptions
WHERE user_id = $1 AND id NOT IN (
SELECT id FROM push_subscriptions
WHERE user_id = $1
ORDER BY last_used_at DESC NULLS LAST
LIMIT $2
)`,
userID, keep,
)
if err != nil {
return fmt.Errorf("prune push subscriptions: %w", err)
}
return nil
}
// 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 {
+142
View File
@@ -0,0 +1,142 @@
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
// For the evening "incomplete chanting" reminder.
RoundsToday int // rounds chanted so far today
TargetRounds int // the user's daily target (16 if unset)
LastChantIncompleteOn *time.Time // UTC date the evening reminder last fired
}
// 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),
COALESCE((SELECT l.rounds_completed FROM sadhna_logs l
WHERE l.user_id = u.id
AND l.log_date = (now() AT TIME ZONE 'UTC')::date), 0),
COALESCE(u.daily_chanting_rounds, 16),
u.last_chant_incomplete_reminder_on
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
OR u.last_chant_incomplete_reminder_on IS NULL OR u.last_chant_incomplete_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,
&c.RoundsToday, &c.TargetRounds, &c.LastChantIncompleteOn,
); 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
}
// MarkChantIncompleteReminded stamps the UTC date the evening "finish your
// rounds" reminder went out, so it fires at most once per day.
func (r *ReminderRepo) MarkChantIncompleteReminded(ctx context.Context, userID string, utcDate time.Time) error {
_, err := r.pg.Pool.Exec(ctx,
`UPDATE users SET last_chant_incomplete_reminder_on = $1 WHERE id = $2`, utcDate, userID)
return err
}
+81
View File
@@ -0,0 +1,81 @@
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} }
// UserTimezone returns the user's stored IANA timezone, or "" if unset — the
// caller falls back to a default. Kept here so the sādhana day boundary can
// be the user's local midnight.
func (r *SadhnaRepo) UserTimezone(ctx context.Context, userID string) (string, error) {
var tz string
err := r.pg.Pool.QueryRow(ctx,
`SELECT COALESCE(NULLIF(timezone, ''), '') FROM users WHERE id = $1`, userID,
).Scan(&tz)
if err != nil {
return "", err
}
return tz, nil
}
// 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"
}
]
}
+87 -2
View File
@@ -93,6 +93,66 @@ func (r *FollowRepo) Status(ctx context.Context, followerID, followingID string)
}
// CountFollowers counts accepted edges pointing at userID.
// ListFollowers returns the accepted followers of userID (users who follow
// them), newest first, optionally filtered by a name/handle search `q`.
// Returns the page plus the total matching count (for pagination).
func (r *FollowRepo) ListFollowers(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
return r.listRelations(ctx, "f.following_id = $1", "f.follower_id", userID, q, limit, offset)
}
// ListFollowing returns the users userID follows (accepted), newest first,
// optionally filtered by `q`. Returns the page plus the total count.
func (r *FollowRepo) ListFollowing(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
return r.listRelations(ctx, "f.follower_id = $1", "f.following_id", userID, q, limit, offset)
}
// listRelations powers both directions: `whereOwn` pins the row to userID on
// one side, `joinCol` is the follows column holding the *other* user (the one
// we join to users and return). `q` filters by name/handle (ILIKE); empty `q`
// disables the filter. The column expressions are fixed internal constants —
// never user input — so this stays injection-safe.
func (r *FollowRepo) listRelations(
ctx context.Context, whereOwn, joinCol, userID, q string, limit, offset int,
) ([]model.UserSummary, int, error) {
like := "%" + q + "%"
var total int
countSQL := `
SELECT count(*)
FROM follows f JOIN users u ON u.id = ` + joinCol + `
WHERE ` + whereOwn + ` AND f.status = 'accepted'
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)`
if err := r.pg.Pool.QueryRow(ctx, countSQL, userID, q, like).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count relations: %w", err)
}
if total == 0 {
return []model.UserSummary{}, 0, nil
}
listSQL := `
SELECT u.handle, u.name, u.avatar_url
FROM follows f JOIN users u ON u.id = ` + joinCol + `
WHERE ` + whereOwn + ` AND f.status = 'accepted'
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
ORDER BY f.created_at DESC, u.handle ASC
LIMIT $4 OFFSET $5`
rows, err := r.pg.Pool.Query(ctx, listSQL, userID, q, like, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("list relations: %w", err)
}
defer rows.Close()
out := make([]model.UserSummary, 0, limit)
for rows.Next() {
var s model.UserSummary
if err := rows.Scan(&s.Handle, &s.Name, &s.AvatarURL); err != nil {
return nil, 0, fmt.Errorf("scan relation: %w", err)
}
out = append(out, s)
}
return out, total, rows.Err()
}
func (r *FollowRepo) CountFollowers(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
@@ -259,7 +319,8 @@ func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *s
// notifications or since-deleted actors).
func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT n.id, n.user_id, n.actor_id, n.kind, n.created_at, n.read_at,
SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count,
n.created_at, n.read_at,
u.name, u.handle, u.avatar_url
FROM notifications n
LEFT JOIN users u ON u.id = n.actor_id
@@ -274,7 +335,8 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
out := []model.Notification{}
for rows.Next() {
var n model.Notification
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt,
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.PostID, &n.ActorCount,
&n.CreatedAt, &n.ReadAt,
&n.ActorName, &n.ActorHandle, &n.ActorAvatar); err != nil {
return nil, fmt.Errorf("scan notification: %w", err)
}
@@ -283,6 +345,29 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
return out, rows.Err()
}
// UpsertLike records (or aggregates) a like notification: one row per
// (recipient, post). On a repeat like it refreshes the most-recent actor,
// bumps actor_count, and re-marks the row unread + most-recent. Returns the
// new actor_count.
func (r *NotificationRepo) UpsertLike(ctx context.Context, userID, actorID, postID string) (int, error) {
var count int
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO notifications (user_id, actor_id, kind, post_id, actor_count)
VALUES ($1, $2, 'post_like', $3, 1)
ON CONFLICT (user_id, post_id) WHERE kind = 'post_like'
DO UPDATE SET actor_id = EXCLUDED.actor_id,
actor_count = notifications.actor_count + 1,
read_at = NULL,
created_at = now()
RETURNING actor_count`,
userID, actorID, postID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf("upsert like notification: %w", err)
}
return count, nil
}
// CountUnread returns how many of a recipient's notifications are unread.
func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) {
var n int
+92 -3
View File
@@ -17,6 +17,7 @@ import (
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
"go.uber.org/zap"
)
@@ -32,6 +33,7 @@ type Server struct {
// Worker lifecycle
workerCancel context.CancelFunc
workerCtx context.Context // derived in initShortener; cancelled on Close
// Handlers
bookH *handler.BookHandler
@@ -54,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
@@ -157,7 +162,10 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
postRepo := repository.NewPostRepo(s.pg)
bookmarkRepo := repository.NewBookmarkRepo(s.pg)
collectionRepo := repository.NewCollectionRepo(s.pg)
postSvc := service.NewPostService(postRepo, r2)
// Likes: membership in Postgres, count delta batched through Redis
// (s.redis may be nil — LikeRepo then applies counts directly).
likeRepo := repository.NewLikeRepo(s.pg, s.redis, logger)
postSvc := service.NewPostService(postRepo, r2, likeRepo)
bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo)
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
@@ -197,12 +205,52 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
blockRepo := repository.NewBlockRepo(s.pg)
reportRepo := repository.NewReportRepo(s.pg)
notifRepo := repository.NewNotificationRepo(s.pg)
notifSvc := service.NewNotificationService(notifRepo, userRepo, mail, logger, cfg.FrontendURL)
rtClient := realtime.NewSoketiClient(
cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret,
cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger,
)
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)
// Wire the notification seam into the post service so a new like notifies
// the author (push + realtime; not the liker themselves).
postSvc.SetNotifier(notifSvc)
// 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)
// Likes worker: folds the pending Redis like-deltas into posts.likes_count
// every 15 minutes (no-op when Redis is unavailable).
worker.NewLikesWorker(likeRepo, 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.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc, likeRepo)
// 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.
@@ -219,6 +267,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 {
@@ -592,6 +641,8 @@ func (s *Server) setupRouter() {
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/{id}", s.postH.Get)
// Paginated "liked by" list — public, like the count itself.
r.Get("/{id}/likes", s.postH.ListLikes)
})
// Protected — create / delete / image presign / "my posts" /
// reshare actions.
@@ -607,6 +658,10 @@ func (s *Server) setupRouter() {
// double-clicks don't 4xx.
r.Post("/{id}/reshare", s.postH.Reshare)
r.Delete("/{id}/reshare", s.postH.Unreshare)
// Like / unlike — idempotent; membership is written now, the
// count is batched into likes_count via Redis.
r.Post("/{id}/like", s.postH.Like)
r.Delete("/{id}/like", s.postH.Unlike)
// Report a post (category + optional reason) → admin email.
r.Post("/{id}/report", s.postH.Report)
})
@@ -670,6 +725,8 @@ func (s *Server) setupRouter() {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/{handle}", s.profileH.Get)
r.Get("/{handle}/feed", s.profileH.Feed)
r.Get("/{handle}/followers", s.profileH.Followers)
r.Get("/{handle}/following", s.profileH.Following)
})
// Relationship actions — follow/block/report. All require a
@@ -735,6 +792,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
+222 -7
View File
@@ -15,12 +15,14 @@ package service
import (
"bytes"
"context"
"fmt"
"html/template"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
"go.uber.org/zap"
)
@@ -28,21 +30,32 @@ import (
const (
NotifFollowRequest = "follow_request"
NotifFollowAccepted = "follow_accepted"
NotifPostLike = "post_like"
// 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"
NotifChantIncomplete = "chant_incomplete_reminder"
)
type NotificationService struct {
notifs *repository.NotificationRepo
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, 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, "/"),
}
@@ -85,6 +98,76 @@ func (s *NotificationService) NotifyFollowAccepted(ctx context.Context, recipien
"%s accepted your follow request — Prabhupāda Says")
}
// NotifyPostLike tells the post author that `likerID` liked their post. Sent
// only when the liker isn't the author (callers also guard). Unlike follows,
// likes are high-volume, so this delivers a persisted row + realtime nudge +
// Web Push but NO email. The push is tagged per-post so repeated likes on the
// same post collapse into one notification rather than stacking.
func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, authorID, likerID string) {
if authorID == "" || likerID == "" || authorID == likerID {
return
}
// Aggregate into the single (author, post) like-notification row, getting
// back how many likers it now represents.
count, err := s.notifs.UpsertLike(ctx, authorID, likerID, postID)
if err != nil {
s.logger.Warn("aggregate like notification failed",
zap.String("recipient", authorID), zap.Error(err))
count = 1
}
actorUser, _ := s.users.FindByID(ctx, likerID)
who := "Someone"
if actorUser != nil {
who = actorDisplay(actorUser)
}
body := likeMessage(who, count)
// Realtime nudge — carry actor_count so the toast can read "and N others".
if s.rt != nil {
payload := map[string]any{"kind": NotifPostLike, "actor_count": count}
if actorUser != nil {
payload["actor_name"] = actorDisplay(actorUser)
payload["actor_handle"] = actorUser.Handle
payload["actor_avatar_url"] = actorUser.AvatarURL
}
if err := s.rt.Trigger(ctx, "notifications."+authorID, "notification", payload); err != nil {
s.logger.Warn("realtime like publish failed", zap.String("recipient", authorID), zap.Error(err))
}
}
// Web Push — tapping opens the author's own feed anchored to the post.
// Tagged per-post so repeated likes collapse into one notification.
if s.push == nil {
return
}
url := s.frontendURL + "/notifications"
if author, err := s.users.FindByID(ctx, authorID); err == nil && author != nil {
url = s.frontendURL + "/u/" + author.Handle + "/posts#post-" + postID
}
s.push.SendToUser(ctx, authorID, PushPayload{
Title: "New Like",
Body: body,
URL: url,
Icon: s.iconURL(),
Tag: NotifPostLike + ":" + postID,
})
}
// likeMessage renders the aggregated like text: "X liked your post" or
// "X and N others liked your post".
func likeMessage(who string, count int) string {
if count <= 1 {
return who + " liked your post"
}
others := count - 1
noun := "others"
if others == 1 {
noun = "other"
}
return fmt.Sprintf("%s and %d %s liked your post", who, others, noun)
}
// dispatch persists the notification and sends the matching email. All
// failures are logged, never propagated.
func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) {
@@ -92,10 +175,25 @@ func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID
if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil {
s.logger.Warn("persist notification failed",
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
// Persistence failed, but still try to email — the email is the
// only delivery channel that exists today.
// Persistence failed, but still try the other channels.
}
// Look up the actor once — both the realtime push and the email use it.
actorUser, aerr := s.users.FindByID(ctx, actorID)
if aerr != nil {
s.logger.Warn("notification actor lookup failed", zap.String("actor", actorID), zap.Error(aerr))
}
// Realtime push (best-effort) — a lightweight nudge so the client can
// bump the bell + toast immediately. Fires regardless of whether the
// 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 {
s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err))
@@ -103,12 +201,11 @@ func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID
}
if recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
// No email on file (OAuth-only account that never added one) —
// the persisted row is the notification; nothing to send.
// the persisted row + realtime push are the notification.
return
}
actorUser, err := s.users.FindByID(ctx, actorID)
if err != nil {
s.logger.Warn("notification actor lookup failed", zap.String("actor", actorID), zap.Error(err))
if actorUser == nil {
// Can't render the email body without the actor's name.
return
}
@@ -124,6 +221,107 @@ func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID
}
}
// publishRealtime pushes a lightweight notification event to the
// recipient's Soketi channel. PUBLIC channel for now ("notifications.{id}")
// — switch to a "private-" prefix once Soketi App auth is wired up. The
// payload carries just enough for a toast; the client refetches the
// authoritative unread count + list from REST.
func (s *NotificationService) publishRealtime(ctx context.Context, recipientID, kind string, actor *model.User) {
if s.rt == nil {
return
}
payload := map[string]any{"kind": kind}
if actor != nil {
payload["actor_name"] = actorDisplay(actor)
payload["actor_handle"] = actor.Handle
payload["actor_avatar_url"] = actor.AvatarURL
}
if err := s.rt.Trigger(ctx, "notifications."+recipientID, "notification", payload); err != nil {
s.logger.Warn("realtime notification publish failed",
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
}
}
// 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, Icon: s.iconURL(), Tag: kind, RequireInteraction: true})
}
// 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
}
// Title must be meaningful, not the app name: iOS renders installed-PWA
// notifications as "<title> from <app name>", so a generic "Prabhupāda
// Says" title reads as "Prabhupāda Says from Prabhupāda Says".
var title, body string
switch kind {
case NotifFollowRequest:
title = "New Friend Request"
body = who + " requested to follow you"
case NotifFollowAccepted:
title = "Friend Request Accepted"
body = who + " accepted your follow request"
default:
title = "New notification"
body = "You have a new notification"
}
s.push.SendToUser(ctx, recipientID, PushPayload{
Title: title,
Body: body,
URL: url,
Icon: s.iconURL(),
Tag: kind,
})
}
// iconURL is the absolute URL of the notification icon (the app's square
// icon), so it renders regardless of the client's base path.
func (s *NotificationService) iconURL() string {
return strings.TrimRight(s.frontendURL, "/") + "/icon-192.png"
}
func (s *NotificationService) render(kind string, recipient, actor *model.User) (string, error) {
data := map[string]any{
"RecipientName": recipient.Name,
@@ -187,3 +385,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>`))
+224
View File
@@ -0,0 +1,224 @@
package service
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
const pushMaxAttempts = 3
// 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"`
// RequireInteraction keeps the notification on screen until the user acts
// on it (used for sādhana reminders, which shouldn't auto-dismiss).
RequireInteraction bool `json:"require_interaction,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
}
// webpush-go prepends "mailto:" to any non-https subscriber, so a subject
// already in "mailto:foo@bar" form would become "mailto:mailto:foo@bar" —
// a malformed VAPID `sub` that Apple rejects with BadJwtToken (FCM is
// lenient). Hand the library a bare address (or an https: URL) so it adds
// exactly one prefix.
subject = strings.TrimSpace(subject)
if !strings.HasPrefix(subject, "https:") {
subject = strings.TrimPrefix(subject, "mailto:")
}
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
}
var sent, pruned, failed int
for _, s := range subs {
sub := &webpush.Subscription{
Endpoint: s.Endpoint,
Keys: webpush.Keys{P256dh: s.P256dh, Auth: s.Auth},
}
status, respBody, err := p.sendWithRetry(ctx, body, sub)
if err != nil {
failed++
p.logger.Warn("push: send failed",
zap.String("user", userID),
zap.String("host", endpointHost(s.Endpoint)),
zap.Error(err))
continue
}
switch {
// Subscription expired/gone — drop it so we stop trying.
case status == http.StatusNotFound || status == http.StatusGone:
pruned++
if derr := p.repo.DeleteByEndpoint(ctx, s.Endpoint); derr != nil {
p.logger.Warn("push: prune failed", zap.Error(derr))
}
case status >= 400:
failed++
// The response body carries the real reason (e.g. a VAPID
// credential mismatch, malformed request) — log it.
p.logger.Warn("push: non-2xx",
zap.Int("status", status),
zap.String("user", userID),
zap.String("host", endpointHost(s.Endpoint)),
zap.String("body", respBody))
default:
sent++
p.logger.Debug("push: sent",
zap.String("user", userID),
zap.String("host", endpointHost(s.Endpoint)))
}
}
p.logger.Debug("push: delivery summary",
zap.String("user", userID),
zap.Int("sent", sent), zap.Int("pruned", pruned), zap.Int("failed", failed))
}
// sendWithRetry posts one notification, retrying on transient failures
// (network error or 429/5xx) with a short backoff that honors Retry-After.
// Returns the final HTTP status and, on a 4xx/5xx, a snippet of the response
// body (the push service's error reason). Returns an error if every attempt
// errored at the transport level.
func (p *PushSender) sendWithRetry(ctx context.Context, body []byte, sub *webpush.Subscription) (int, string, error) {
var lastErr error
for attempt := 1; attempt <= pushMaxAttempts; attempt++ {
resp, err := webpush.SendNotification(body, sub, &webpush.Options{
Subscriber: p.subject,
VAPIDPublicKey: p.pub,
VAPIDPrivateKey: p.priv,
// Hold the message for up to 6h so a sleeping/offline device
// still gets it on wake (60s would silently expire meanwhile).
TTL: 6 * 60 * 60,
Urgency: webpush.UrgencyNormal,
})
if err != nil {
lastErr = err
if attempt < pushMaxAttempts {
sleepCtx(ctx, backoff(attempt))
continue
}
return 0, "", err
}
status := resp.StatusCode
retryAfter := resp.Header.Get("Retry-After")
if isTransientStatus(status) && attempt < pushMaxAttempts {
_ = resp.Body.Close()
sleepCtx(ctx, retryDelay(retryAfter, attempt))
continue
}
// Final attempt — capture the body on errors for diagnostics.
var snippet string
if status >= 400 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
snippet = strings.TrimSpace(string(b))
}
_ = resp.Body.Close()
return status, snippet, nil
}
return 0, "", lastErr
}
// endpointHost extracts the host of a push endpoint for log context.
func endpointHost(endpoint string) string {
if u, err := url.Parse(endpoint); err == nil {
return u.Host
}
return ""
}
func isTransientStatus(s int) bool {
switch s {
case http.StatusTooManyRequests, // 429
http.StatusInternalServerError, // 500
http.StatusBadGateway, // 502
http.StatusServiceUnavailable, // 503
http.StatusGatewayTimeout: // 504
return true
}
return false
}
func backoff(attempt int) time.Duration {
return time.Duration(attempt) * 500 * time.Millisecond
}
// retryDelay prefers the push service's Retry-After (seconds), capped, else a
// plain backoff.
func retryDelay(retryAfter string, attempt int) time.Duration {
if retryAfter != "" {
if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 {
d := time.Duration(secs) * time.Second
if d > 30*time.Second {
d = 30 * time.Second
}
return d
}
}
return backoff(attempt)
}
// sleepCtx waits for d, returning early if the context is cancelled.
func sleepCtx(ctx context.Context, d time.Duration) {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
case <-t.C:
}
}
+202
View File
@@ -0,0 +1,202 @@
// 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"
"fmt"
"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
// The evening "finish your rounds" reminder fires at 21:00 local. Kept
// before the 21:30 quote reminder so the two don't collide in one tick.
chantIncompleteTime = "21:00"
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))
}
}
}
// Evening "incomplete chanting" reminder — the devotee started their
// rounds today (RoundsToday > 0) but hasn't reached the target by
// 21:00 local. Only nudges those who began, so non-starters get just
// the morning reminder, not a second one. Independent of the morning
// chant reminder (own time + own per-day dedupe).
if c.RoundsToday > 0 && !c.ChantDoneToday && notRemindedToday(c.LastChantIncompleteOn, utcToday) {
if t := parseHHMM(chantIncompleteTime); t >= 0 && withinWindow(localMin, t) {
s.notif.SendReminder(ctx, c.UserID, NotifChantIncomplete,
"Finish your japa 🙏", incompleteChantMessage(c.RoundsToday, c.TargetRounds), s.sadhnaURL)
if err := s.repo.MarkChantIncompleteReminded(ctx, c.UserID, utcToday); err != nil {
s.logger.Warn("reminders: mark chant-incomplete 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
}
}
// incompleteChantMessage nudges a devotee who began their rounds today to
// finish them (only sent when rounds > 0).
func incompleteChantMessage(rounds, target int) string {
if target <= 0 {
target = 16
}
return fmt.Sprintf(
"You've chanted %d of %d rounds today — finish your japa before the day ends. 🙏",
rounds, target)
}
// 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
}
+470
View File
@@ -0,0 +1,470 @@
// 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"
// fallbackTZ is used when the user has no stored timezone — the sādhana day
// boundary (and thus the nightly chant reset) defaults to India time.
const fallbackTZ = "Asia/Kolkata"
// resolveTZ returns the user's IANA timezone name + loaded location, falling
// back to Asia/Kolkata (then UTC) so the day boundary is always defined.
//
// The users.timezone column defaults to 'UTC' (migration 0028), so a bare
// "UTC" means the browser never reported a real zone — i.e. we don't actually
// know the user's local time. Per product intent we treat that as unknown and
// fall back to India time, rather than literally bucketing their day at UTC
// midnight (which mismatches the browser-local calendar on the client). A
// genuine UTC user re-reports their real zone on next load.
func (s *SadhnaService) resolveTZ(ctx context.Context, userID string) (string, *time.Location) {
tz, _ := s.logs.UserTimezone(ctx, userID)
if tz == "" || tz == "UTC" {
tz = fallbackTZ
}
loc, err := time.LoadLocation(tz)
if err != nil {
tz = fallbackTZ
if loc, err = time.LoadLocation(fallbackTZ); err != nil {
return "UTC", time.UTC
}
}
return tz, loc
}
// todayIn returns the current calendar date in loc, anchored at UTC midnight
// so it round-trips cleanly through Postgres DATE columns and dayFmt. This is
// the anchor every day-bucket comparison uses — the day rolls over at the
// user's local midnight.
func todayIn(loc *time.Location) time.Time {
now := time.Now().In(loc)
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
// Bucket against the user's local "today" so progress resets at their
// local midnight (fallback Asia/Kolkata), not UTC midnight.
_, loc := s.resolveTZ(ctx, userID)
if _, err := s.logs.UpsertToday(ctx, userID, todayIn(loc), 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)
tz, loc := s.resolveTZ(ctx, userID)
today := todayIn(loc)
since := today.AddDate(0, 0, -(streakWindowDays - 1))
logs, err := s.logs.ListSince(ctx, userID, since)
if err != nil {
s.logger.Error("sadhna dashboard: list logs failed",
zap.String("user", userID), zap.Error(err))
return nil, err
}
// The quote half is non-critical: if it fails (e.g. an unrecognized
// timezone in the AT TIME ZONE bucketing), log it and degrade to "no
// quotes" rather than 500-ing the whole dashboard.
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, since, tz)
if err != nil {
s.logger.Error("sadhna dashboard: post counts failed",
zap.String("user", userID), zap.String("tz", tz), zap.Error(err))
postsByDay = map[string]int{}
}
// 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)
// Only project a next-gift date when a streak is actually in
// progress. With no live streak (current == 0) there's nothing to
// "continue", so the calendar shows no gift marker until the devotee
// completes a day (full rounds AND a quote).
if current > 0 {
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) {
tz, _ := s.resolveTZ(ctx, userID)
logs, err := s.logs.ListSince(ctx, userID, from)
if err != nil {
s.logger.Error("sadhna history: list logs failed",
zap.String("user", userID), zap.Error(err))
return nil, err
}
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, from, tz)
if err != nil {
s.logger.Error("sadhna history: post counts failed",
zap.String("user", userID), zap.String("tz", tz), zap.Error(err))
postsByDay = map[string]int{}
}
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"
}
}
+19
View File
@@ -310,6 +310,25 @@ func (s *SocialGraphService) Counts(ctx context.Context, userID string) (followe
return followers, following, nil
}
// Followers / Following resolve the handle and return one page of the
// profile's accepted followers (or followings), optionally filtered by a
// name/handle search `q`, plus the total matching count for pagination.
func (s *SocialGraphService) Followers(ctx context.Context, handle, q string, limit, offset int) ([]model.UserSummary, int, error) {
u, err := s.users.FindByHandle(ctx, handle)
if err != nil {
return nil, 0, err
}
return s.follows.ListFollowers(ctx, u.ID, q, limit, offset)
}
func (s *SocialGraphService) Following(ctx context.Context, handle, q string, limit, offset int) ([]model.UserSummary, int, error) {
u, err := s.users.FindByHandle(ctx, handle)
if err != nil {
return nil, 0, err
}
return s.follows.ListFollowing(ctx, u.ID, q, limit, offset)
}
// ProfileExtras is the read used by the public-profile endpoint: the
// target's follower/following counts plus, when a non-owner viewer is
// signed in, their relationship to the target. rel is nil for anonymous
+63 -2
View File
@@ -33,12 +33,18 @@ var (
type PostService struct {
posts *repository.PostRepo
r2 *repository.R2
likes *repository.LikeRepo
notif *NotificationService // optional; set via SetNotifier
}
func NewPostService(posts *repository.PostRepo, r2 *repository.R2) *PostService {
return &PostService{posts: posts, r2: r2}
func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo) *PostService {
return &PostService{posts: posts, r2: r2, likes: likes}
}
// SetNotifier wires the notification seam after construction (the
// NotificationService is built later in server wiring than PostService).
func (s *PostService) SetNotifier(n *NotificationService) { s.notif = n }
// Create persists a new post. The caption is treated as opaque — we trust
// the frontend to send the exact text the user selected. Source URL must be
// a same-origin path (e.g. /books/bg/1/1?hl=…) — we strip the host if one
@@ -72,6 +78,9 @@ func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post
if err := s.hydrateReshareStates(ctx, viewerID, []*model.Post{p}); err != nil {
return nil, err
}
if err := s.likes.Hydrate(ctx, viewerID, []*model.Post{p}); err != nil {
return nil, err
}
return p, nil
}
@@ -171,9 +180,58 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string,
if err := s.hydrateReshareStatesSlice(ctx, viewerID, posts); err != nil {
return nil, 0, err
}
if err := s.hydrateLikesSlice(ctx, viewerID, posts); err != nil {
return nil, 0, err
}
return posts, total, nil
}
// hydrateLikesSlice fills LikesCount + ViewerHasLiked on a value slice.
func (s *PostService) hydrateLikesSlice(ctx context.Context, viewerID string, posts []model.Post) error {
if len(posts) == 0 {
return nil
}
ptrs := make([]*model.Post, len(posts))
for i := range posts {
ptrs[i] = &posts[i]
}
return s.likes.Hydrate(ctx, viewerID, ptrs)
}
// Like / Unlike toggle the caller's like on a post and return the effective
// total (likes_count + pending Redis delta).
func (s *PostService) Like(ctx context.Context, userID, postID string) (int, error) {
changed, err := s.likes.Like(ctx, postID, userID)
if err != nil {
return 0, err
}
count, err := s.likes.EffectiveCount(ctx, postID)
if err != nil {
return 0, err
}
// Notify the author on a genuinely new like by someone else. Fire-and-
// forget on a detached context so it isn't cancelled when the request
// returns; NotifyPostLike re-checks self-like internally too.
if changed && s.notif != nil {
if authorID, aerr := s.likes.PostAuthor(ctx, postID); aerr == nil && authorID != userID {
go s.notif.NotifyPostLike(context.Background(), postID, authorID, userID)
}
}
return count, nil
}
func (s *PostService) Unlike(ctx context.Context, userID, postID string) (int, error) {
if _, err := s.likes.Unlike(ctx, postID, userID); err != nil {
return 0, err
}
return s.likes.EffectiveCount(ctx, postID)
}
// Likers returns one page of users who liked the post + the total count.
func (s *PostService) Likers(ctx context.Context, postID, q string, limit, offset int) ([]model.UserSummary, int, error) {
return s.likes.ListLikers(ctx, postID, q, limit, offset)
}
// ListMine is the owner's view of their own profile feed. Every post
// they own — authored or reshared — comes from posts.user_id, so the
// viewer is by construction the owner. ViewerHasReshared is true for
@@ -195,6 +253,9 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
posts[i].ViewerHasReshared = true
}
}
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
return nil, 0, err
}
return posts, total, nil
}
+55
View File
@@ -0,0 +1,55 @@
package worker
import (
"context"
"time"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
// LikesWorker periodically folds the pending Redis like-deltas into
// posts.likes_count, so the hot counter is written in batches rather than on
// every like. It also does a best-effort final flush on shutdown so deltas
// aren't lost.
type LikesWorker struct {
repo *repository.LikeRepo
interval time.Duration
logger *zap.Logger
}
func NewLikesWorker(repo *repository.LikeRepo, logger *zap.Logger) *LikesWorker {
return &LikesWorker{repo: repo, interval: 15 * time.Minute, logger: logger}
}
// Run starts the flush loop in its own goroutine; it exits when ctx is done.
func (w *LikesWorker) Run(ctx context.Context) {
go func() {
w.logger.Info("likes worker started", zap.Duration("interval", w.interval))
defer w.logger.Info("likes worker stopped")
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// Final flush so a graceful shutdown doesn't drop pending
// deltas. Fresh, short-lived context since ctx is cancelled.
flushCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
if _, err := w.repo.FlushDeltas(flushCtx); err != nil {
w.logger.Warn("likes worker: final flush failed", zap.Error(err))
}
cancel()
return
case <-ticker.C:
n, err := w.repo.FlushDeltas(ctx)
if err != nil {
w.logger.Warn("likes worker: flush failed", zap.Error(err))
} else if n > 0 {
w.logger.Info("likes worker: flushed", zap.Int("posts", n))
}
}
}
}()
}
+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;
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS last_chant_incomplete_reminder_on;
@@ -0,0 +1,8 @@
-- Evening "incomplete chanting" reminder.
--
-- last_chant_incomplete_reminder_on: the UTC date the 9 PM (local)
-- "finish your rounds" reminder was last sent, so it goes out at most once
-- per day. Distinct from the morning chant reminder so a devotee can get a
-- gentle start-of-day nudge and, if their rounds are still unfinished, an
-- end-of-day one.
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_chant_incomplete_reminder_on DATE;
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS post_likes;
ALTER TABLE posts DROP COLUMN IF EXISTS likes_count;
+26
View File
@@ -0,0 +1,26 @@
-- Post likes.
--
-- `post_likes` is the membership table (one row per user-per-post like),
-- written synchronously so a viewer's like state and the "liked by" list are
-- immediately consistent. The UNIQUE(post_id, user_id) makes liking idempotent.
--
-- `posts.likes_count` is a denormalized counter that is NOT updated on every
-- like (that hot-row UPDATE doesn't scale for popular posts). Instead each
-- like/unlike nudges a per-post delta in Redis, and a background worker folds
-- the deltas into this column every 15 minutes. Reads return
-- likes_count + the still-pending Redis delta, so the displayed total is
-- always current.
ALTER TABLE posts ADD COLUMN IF NOT EXISTS likes_count INTEGER NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS post_likes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (post_id, user_id)
);
-- Listing "who liked this post", newest first.
CREATE INDEX IF NOT EXISTS post_likes_post_idx ON post_likes (post_id, created_at DESC);
-- "Have I liked these posts?" — viewer-state hydration across a feed.
CREATE INDEX IF NOT EXISTS post_likes_user_idx ON post_likes (user_id);
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS notifications_post_like_uq;
ALTER TABLE notifications DROP COLUMN IF EXISTS actor_count;
ALTER TABLE notifications DROP COLUMN IF EXISTS post_id;
@@ -0,0 +1,14 @@
-- Aggregated like notifications.
--
-- Likes are high-volume, so instead of one notification row per like we keep a
-- single row per (recipient, post) and aggregate: actor_id holds the most
-- recent liker, actor_count how many distinct likers have aggregated into it.
-- The partial unique index lets the like path UPSERT that one row (bumping the
-- count, refreshing the actor, re-marking unread) — so the activity list shows
-- "X and N others liked your post", not a flood.
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS post_id UUID REFERENCES posts(id) ON DELETE CASCADE;
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS actor_count INTEGER NOT NULL DEFAULT 1;
CREATE UNIQUE INDEX IF NOT EXISTS notifications_post_like_uq
ON notifications (user_id, post_id)
WHERE kind = 'post_like';
+144
View File
@@ -0,0 +1,144 @@
// Package realtime publishes events to a Soketi (Pusher-compatible)
// WebSocket server so the frontend can receive realtime notifications.
//
// We implement the Pusher REST "trigger" protocol directly (HMAC-SHA256
// signed POST to /apps/{id}/events) rather than pull in a client library —
// it's a small, stable wire format and avoids a new dependency.
//
// Channel auth is intentionally NOT handled here: we publish to PUBLIC
// channels for now (see the notification service). When Soketi Apps are
// configured for connection/channel authentication, switch the channel
// names to the `private-` prefix and add a /broadcasting/auth endpoint —
// the trigger code below is unchanged by that.
package realtime
import (
"bytes"
"context"
"crypto/hmac"
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"go.uber.org/zap"
)
// SoketiClient triggers events on a Soketi/Pusher app. A nil *SoketiClient
// is valid and makes Trigger a no-op, so callers can hold an optional
// client without nil-checks.
type SoketiClient struct {
appID string
key string
secret string
baseURL string // scheme://host[:port]
http *http.Client
logger *zap.Logger
}
// NewSoketiClient returns a configured client, or nil when any required
// credential is missing (realtime disabled — callers degrade gracefully).
func NewSoketiClient(appID, key, secret, host string, port int, useTLS bool, logger *zap.Logger) *SoketiClient {
if appID == "" || key == "" || secret == "" || host == "" {
logger.Info("realtime: Soketi not configured — notifications won't be pushed over WebSocket")
return nil
}
scheme := "http"
if useTLS {
scheme = "https"
}
hostport := host
// Omit the port when it's the scheme default (cleaner, and matches
// what most reverse-proxied setups expect).
if port != 0 && !((useTLS && port == 443) || (!useTLS && port == 80)) {
hostport = fmt.Sprintf("%s:%d", host, port)
}
return &SoketiClient{
appID: appID,
key: key,
secret: secret,
baseURL: scheme + "://" + hostport,
http: &http.Client{Timeout: 5 * time.Second},
logger: logger,
}
}
// Trigger publishes `event` with `data` (JSON-marshalled) to `channel`.
// Best-effort: on a nil client it no-ops; errors are returned for the
// caller to log-and-ignore.
func (c *SoketiClient) Trigger(ctx context.Context, channel, event string, data any) error {
if c == nil {
return nil
}
dataJSON, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("marshal event data: %w", err)
}
// Pusher wraps the payload as a JSON STRING in the `data` field.
body, err := json.Marshal(map[string]any{
"name": event,
"channels": []string{channel},
"data": string(dataJSON),
})
if err != nil {
return fmt.Errorf("marshal trigger body: %w", err)
}
bodyMD5 := fmt.Sprintf("%x", md5.Sum(body))
path := "/apps/" + c.appID + "/events"
// Auth params (all values are signature-safe, no URL-encoding needed),
// sorted alphabetically by key for the canonical string-to-sign.
params := map[string]string{
"auth_key": c.key,
"auth_timestamp": strconv.FormatInt(time.Now().Unix(), 10),
"auth_version": "1.0",
"body_md5": bodyMD5,
}
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
var qs strings.Builder
for i, k := range keys {
if i > 0 {
qs.WriteByte('&')
}
qs.WriteString(k)
qs.WriteByte('=')
qs.WriteString(params[k])
}
query := qs.String()
stringToSign := "POST\n" + path + "\n" + query
mac := hmac.New(sha256.New, []byte(c.secret))
mac.Write([]byte(stringToSign))
signature := hex.EncodeToString(mac.Sum(nil))
fullURL := c.baseURL + path + "?" + query + "&auth_signature=" + signature
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build trigger request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("trigger request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("soketi trigger %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
return nil
}