From f598770b32794f8f7c2b8175b435eba5d7a41212 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Mon, 8 Jun 2026 15:47:44 +0530 Subject: [PATCH] feat: add web push and sadhna tracker --- .env.example | 5 + cmd/uploadrewards/main.go | 152 +++++++ go.mod | 1 + go.sum | 43 ++ internal/config/config.go | 12 + internal/handler/push.go | 89 ++++ internal/handler/reminder.go | 75 ++++ internal/handler/sadhna.go | 124 ++++++ internal/model/sadhna.go | 97 ++++ internal/model/social.go | 9 + internal/repository/post_repo.go | 60 +++ internal/repository/push_repo.go | 64 +++ internal/repository/r2.go | 22 + internal/repository/reminder_repo.go | 122 +++++ internal/repository/sadhna_repo.go | 67 +++ internal/repository/sadhna_reward_repo.go | 218 +++++++++ internal/repository/sadhna_rewards.json | 110 +++++ internal/server/server.go | 68 ++- internal/service/notification_service.go | 97 +++- internal/service/push_sender.go | 98 ++++ internal/service/reminder_service.go | 171 +++++++ internal/service/sadhna_service.go | 418 ++++++++++++++++++ internal/worker/reminder_worker.go | 41 ++ migrations/0025_create_sadhna_logs.down.sql | 1 + migrations/0025_create_sadhna_logs.up.sql | 30 ++ .../0026_create_sadhna_rewards.down.sql | 2 + migrations/0026_create_sadhna_rewards.up.sql | 36 ++ .../0027_create_push_subscriptions.down.sql | 1 + .../0027_create_push_subscriptions.up.sql | 18 + migrations/0028_add_reminder_fields.down.sql | 4 + migrations/0028_add_reminder_fields.up.sql | 14 + 31 files changed, 2267 insertions(+), 2 deletions(-) create mode 100644 cmd/uploadrewards/main.go create mode 100644 internal/handler/push.go create mode 100644 internal/handler/reminder.go create mode 100644 internal/handler/sadhna.go create mode 100644 internal/model/sadhna.go create mode 100644 internal/repository/push_repo.go create mode 100644 internal/repository/reminder_repo.go create mode 100644 internal/repository/sadhna_repo.go create mode 100644 internal/repository/sadhna_reward_repo.go create mode 100644 internal/repository/sadhna_rewards.json create mode 100644 internal/service/push_sender.go create mode 100644 internal/service/reminder_service.go create mode 100644 internal/service/sadhna_service.go create mode 100644 internal/worker/reminder_worker.go create mode 100644 migrations/0025_create_sadhna_logs.down.sql create mode 100644 migrations/0025_create_sadhna_logs.up.sql create mode 100644 migrations/0026_create_sadhna_rewards.down.sql create mode 100644 migrations/0026_create_sadhna_rewards.up.sql create mode 100644 migrations/0027_create_push_subscriptions.down.sql create mode 100644 migrations/0027_create_push_subscriptions.up.sql create mode 100644 migrations/0028_add_reminder_fields.down.sql create mode 100644 migrations/0028_add_reminder_fields.up.sql diff --git a/.env.example b/.env.example index 23d9e09..b7f4cf8 100644 --- a/.env.example +++ b/.env.example @@ -66,3 +66,8 @@ SOKETI_APP_SECRET= SOKETI_HOST=ws.prabhupadasays.org SOKETI_PORT=443 SOKETI_USE_TLS=true + +# Web Push +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT= \ No newline at end of file diff --git a/cmd/uploadrewards/main.go b/cmd/uploadrewards/main.go new file mode 100644 index 0000000..5727433 --- /dev/null +++ b/cmd/uploadrewards/main.go @@ -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 +// `/` 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 +} diff --git a/go.mod b/go.mod index 640c1a7..77b070c 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index b503c2e..cc5dde2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go index 4786720..ef9490e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -89,6 +89,14 @@ type Config struct { SoketiHost string SoketiPort int SoketiUseTLS bool + + // Web Push (VAPID). Empty keys disable push delivery (other channels + // still run). VAPIDSubject is a mailto: or https: contact URL required + // by the spec. The public key is also served to the browser to + // subscribe. + VAPIDPublicKey string + VAPIDPrivateKey string + VAPIDSubject string } // Load reads configuration from environment variables (with .env fallback). @@ -154,6 +162,10 @@ func Load() (*Config, error) { SoketiHost: getEnv("SOKETI_HOST", "ws.prabhupadasays.org"), SoketiPort: getEnvInt("SOKETI_PORT", 443), SoketiUseTLS: getEnvBool("SOKETI_USE_TLS", true), + + VAPIDPublicKey: getEnv("VAPID_PUBLIC_KEY", ""), + VAPIDPrivateKey: getEnv("VAPID_PRIVATE_KEY", ""), + VAPIDSubject: getEnv("VAPID_SUBJECT", "mailto:admin@prabhupadasays.org"), } if cfg.MongoURI == "" { diff --git a/internal/handler/push.go b/internal/handler/push.go new file mode 100644 index 0000000..650293a --- /dev/null +++ b/internal/handler/push.go @@ -0,0 +1,89 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/prabhupadasays/spsays-bff/internal/middleware" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "github.com/prabhupadasays/spsays-bff/internal/service" + "github.com/prabhupadasays/spsays-bff/pkg/response" +) + +// PushHandler serves Web Push subscription management. The VAPID public key +// is handed to the browser so it can subscribe; subscriptions are stored per +// user. All routes require auth. +type PushHandler struct { + push *service.PushSender // optional — nil when VAPID isn't configured + repo *repository.PushRepo +} + +func NewPushHandler(push *service.PushSender, repo *repository.PushRepo) *PushHandler { + return &PushHandler{push: push, repo: repo} +} + +// Key — GET /push/key. Returns the VAPID public key + whether push is on. +func (h *PushHandler) Key(w http.ResponseWriter, r *http.Request) { + response.JSON(w, http.StatusOK, struct { + PublicKey string `json:"public_key"` + Enabled bool `json:"enabled"` + }{ + PublicKey: h.push.PublicKey(), + Enabled: h.push.Enabled(), + }) +} + +type subscribeReq struct { + Endpoint string `json:"endpoint"` + Keys struct { + P256dh string `json:"p256dh"` + Auth string `json:"auth"` + } `json:"keys"` +} + +// Subscribe — POST /push/subscribe. Stores (or refreshes) the caller's push +// subscription. Body is the browser PushSubscription JSON. +func (h *PushHandler) Subscribe(w http.ResponseWriter, r *http.Request) { + if h.repo == nil || !h.push.Enabled() { + response.Error(w, http.StatusServiceUnavailable, "push not configured") + return + } + uid := middleware.UserID(r.Context()) + var req subscribeReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + response.Error(w, http.StatusBadRequest, "invalid JSON body") + return + } + if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" { + response.Error(w, http.StatusBadRequest, "missing subscription fields") + return + } + if err := h.repo.Upsert(r.Context(), uid, req.Endpoint, req.Keys.P256dh, req.Keys.Auth, r.UserAgent()); err != nil { + response.Error(w, http.StatusInternalServerError, "could not save subscription") + return + } + response.NoContent(w) +} + +type unsubscribeReq struct { + Endpoint string `json:"endpoint"` +} + +// Unsubscribe — POST /push/unsubscribe. Removes a subscription by endpoint. +func (h *PushHandler) Unsubscribe(w http.ResponseWriter, r *http.Request) { + if h.repo == nil { + response.NoContent(w) + return + } + var req unsubscribeReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Endpoint) == "" { + response.Error(w, http.StatusBadRequest, "missing endpoint") + return + } + if err := h.repo.DeleteByEndpoint(r.Context(), req.Endpoint); err != nil { + response.Error(w, http.StatusInternalServerError, "could not remove subscription") + return + } + response.NoContent(w) +} diff --git a/internal/handler/reminder.go b/internal/handler/reminder.go new file mode 100644 index 0000000..35e5444 --- /dev/null +++ b/internal/handler/reminder.go @@ -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) +} diff --git a/internal/handler/sadhna.go b/internal/handler/sadhna.go new file mode 100644 index 0000000..8816d32 --- /dev/null +++ b/internal/handler/sadhna.go @@ -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) +} diff --git a/internal/model/sadhna.go b/internal/model/sadhna.go new file mode 100644 index 0000000..38bfb0a --- /dev/null +++ b/internal/model/sadhna.go @@ -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"` +} diff --git a/internal/model/social.go b/internal/model/social.go index cd26640..c490b82 100644 --- a/internal/model/social.go +++ b/internal/model/social.go @@ -171,3 +171,12 @@ type Notification struct { ActorHandle *string `json:"actor_handle,omitempty"` ActorAvatar *string `json:"actor_avatar_url,omitempty"` } + +// PushSubscription is one browser/device Web Push endpoint a user granted. +type PushSubscription struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Endpoint string `json:"endpoint"` + P256dh string `json:"p256dh"` + Auth string `json:"auth"` +} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index 824c508..e42a43b 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/jackc/pgx/v5" "github.com/prabhupadasays/spsays-bff/internal/model" @@ -215,6 +216,65 @@ func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) return n, nil } +// PostedDates returns the set of UTC calendar days, on or after `since`, +// on which the user created at least one post. Used by the sādhana streak +// to detect the "posted a quote today" discipline (and to score past days +// of the streak) without loading whole posts. Keys are "YYYY-MM-DD". +func (r *PostRepo) PostedDates(ctx context.Context, userID string, since time.Time) (map[string]bool, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT DISTINCT (created_at AT TIME ZONE 'UTC')::date AS d + FROM posts + WHERE user_id = $1 AND created_at >= $2`, + userID, since, + ) + if err != nil { + return nil, fmt.Errorf("posted dates: %w", err) + } + defer rows.Close() + + out := make(map[string]bool) + for rows.Next() { + var d time.Time + if err := rows.Scan(&d); err != nil { + return nil, fmt.Errorf("scan posted date: %w", err) + } + out[d.Format("2006-01-02")] = true + } + return out, rows.Err() +} + +// PostCountsByDate returns, for each UTC calendar day on or after `since`, +// how many posts the user created that day. Keys are "YYYY-MM-DD"; days +// with no posts are absent (so a missing key reads as zero). The sādhana +// vine uses this both for the "posted a quote today" discipline and to +// annotate each day's fold with the number of quotes shared. +func (r *PostRepo) PostCountsByDate(ctx context.Context, userID string, since time.Time) (map[string]int, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT (created_at AT TIME ZONE 'UTC')::date AS d, count(*) AS n + FROM posts + WHERE user_id = $1 AND created_at >= $2 + GROUP BY d`, + userID, since, + ) + if err != nil { + return nil, fmt.Errorf("post counts by date: %w", err) + } + defer rows.Close() + + out := make(map[string]int) + for rows.Next() { + var ( + d time.Time + n int + ) + if err := rows.Scan(&d, &n); err != nil { + return nil, fmt.Errorf("scan post count: %w", err) + } + out[d.Format("2006-01-02")] = n + } + return out, rows.Err() +} + // Reshare creates a new posts row owned by `resharerID` that points at // the canonical original via `reshared_from_post_id`. Copies the // original's caption / source_url / source_title and its current images diff --git a/internal/repository/push_repo.go b/internal/repository/push_repo.go new file mode 100644 index 0000000..1100ad0 --- /dev/null +++ b/internal/repository/push_repo.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/prabhupadasays/spsays-bff/internal/model" +) + +// PushRepo stores Web Push subscriptions (one per browser/device endpoint). +type PushRepo struct{ pg *Postgres } + +func NewPushRepo(pg *Postgres) *PushRepo { return &PushRepo{pg: pg} } + +// Upsert records (or refreshes) a subscription. The endpoint is unique, so a +// browser re-subscribing — possibly under a different user — rebinds cleanly. +func (r *PushRepo) Upsert(ctx context.Context, userID, endpoint, p256dh, auth, userAgent string) error { + _, err := r.pg.Pool.Exec(ctx, ` + INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, user_agent) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (endpoint) DO UPDATE + SET user_id = EXCLUDED.user_id, + p256dh = EXCLUDED.p256dh, + auth = EXCLUDED.auth, + user_agent = EXCLUDED.user_agent, + last_used_at = now()`, + userID, endpoint, p256dh, auth, userAgent, + ) + if err != nil { + return fmt.Errorf("upsert push subscription: %w", err) + } + return nil +} + +// ListByUser returns the user's active subscriptions. +func (r *PushRepo) ListByUser(ctx context.Context, userID string) ([]model.PushSubscription, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT id, user_id, endpoint, p256dh, auth + FROM push_subscriptions WHERE user_id = $1`, userID) + if err != nil { + return nil, fmt.Errorf("list push subscriptions: %w", err) + } + defer rows.Close() + var out []model.PushSubscription + for rows.Next() { + var s model.PushSubscription + if err := rows.Scan(&s.ID, &s.UserID, &s.Endpoint, &s.P256dh, &s.Auth); err != nil { + return nil, fmt.Errorf("scan push subscription: %w", err) + } + out = append(out, s) + } + return out, rows.Err() +} + +// DeleteByEndpoint removes a subscription (on unsubscribe or when the push +// service reports it gone). +func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error { + _, err := r.pg.Pool.Exec(ctx, + `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint) + if err != nil { + return fmt.Errorf("delete push subscription: %w", err) + } + return nil +} diff --git a/internal/repository/r2.go b/internal/repository/r2.go index 0c57421..0cd6fb4 100644 --- a/internal/repository/r2.go +++ b/internal/repository/r2.go @@ -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 { diff --git a/internal/repository/reminder_repo.go b/internal/repository/reminder_repo.go new file mode 100644 index 0000000..d1cb8a2 --- /dev/null +++ b/internal/repository/reminder_repo.go @@ -0,0 +1,122 @@ +package repository + +import ( + "context" + "fmt" + "time" +) + +// ReminderRepo backs the sādhana reminder scheduler: per-user preferences, +// the timezone used to fire at local time, and the candidate query the +// worker scans each tick. +type ReminderRepo struct{ pg *Postgres } + +func NewReminderRepo(pg *Postgres) *ReminderRepo { return &ReminderRepo{pg: pg} } + +// ReminderCandidate is one user the worker evaluates per tick. The time-of-day +// windowing + per-day dedupe decision happens in Go (using TZ); the SQL just +// supplies the inputs, including whether today's disciplines are already done +// (bucketed by UTC day, matching the streak logic). +type ReminderCandidate struct { + UserID string + Email *string + TZ string + ChantTime string // "HH:MM" local + QuoteTime string // "HH:MM" local + LastChantOn *time.Time + LastQuoteOn *time.Time + LastChantDate *time.Time // most recent completed chant day (UTC), nil if never + ChantDoneToday bool + QuoteDoneToday bool +} + +// SetTimezone records the user's IANA timezone (validated by the caller). +func (r *ReminderRepo) SetTimezone(ctx context.Context, userID, tz string) error { + _, err := r.pg.Pool.Exec(ctx, `UPDATE users SET timezone = $1 WHERE id = $2`, tz, userID) + if err != nil { + return fmt.Errorf("set timezone: %w", err) + } + return nil +} + +// GetEnabled returns whether the user wants sādhana reminders. +func (r *ReminderRepo) GetEnabled(ctx context.Context, userID string) (bool, error) { + var enabled bool + err := r.pg.Pool.QueryRow(ctx, + `SELECT sadhna_reminders_enabled FROM users WHERE id = $1`, userID).Scan(&enabled) + if err != nil { + return false, fmt.Errorf("get reminder pref: %w", err) + } + return enabled, nil +} + +// SetEnabled flips the user's reminder opt-in. +func (r *ReminderRepo) SetEnabled(ctx context.Context, userID string, enabled bool) error { + _, err := r.pg.Pool.Exec(ctx, + `UPDATE users SET sadhna_reminders_enabled = $1 WHERE id = $2`, enabled, userID) + if err != nil { + return fmt.Errorf("set reminder pref: %w", err) + } + return nil +} + +// DueCandidates returns reminder-enabled users who haven't already had BOTH +// reminders today, with the data the worker needs to decide. Defaults are +// folded in (06:00 chant, 21:30 quote, UTC tz). +func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT + u.id, + u.email, + COALESCE(NULLIF(u.timezone, ''), 'UTC'), + COALESCE(NULLIF(u.preferred_chanting_time, ''), '06:00'), + COALESCE(NULLIF(u.peak_active_time, ''), '21:30'), + u.last_chant_reminder_on, + u.last_quote_reminder_on, + (SELECT max(l.log_date) FROM sadhna_logs l + WHERE l.user_id = u.id AND l.chant_done), + EXISTS(SELECT 1 FROM sadhna_logs l + WHERE l.user_id = u.id AND l.chant_done + AND l.log_date = (now() AT TIME ZONE 'UTC')::date), + EXISTS(SELECT 1 FROM posts p + WHERE p.user_id = u.id + AND (p.created_at AT TIME ZONE 'UTC')::date = (now() AT TIME ZONE 'UTC')::date) + FROM users u + WHERE u.sadhna_reminders_enabled = TRUE + AND ( + u.last_chant_reminder_on IS NULL OR u.last_chant_reminder_on < (now() AT TIME ZONE 'UTC')::date + OR u.last_quote_reminder_on IS NULL OR u.last_quote_reminder_on < (now() AT TIME ZONE 'UTC')::date + )`) + if err != nil { + return nil, fmt.Errorf("due candidates: %w", err) + } + defer rows.Close() + + var out []ReminderCandidate + for rows.Next() { + var c ReminderCandidate + if err := rows.Scan( + &c.UserID, &c.Email, &c.TZ, &c.ChantTime, &c.QuoteTime, + &c.LastChantOn, &c.LastQuoteOn, &c.LastChantDate, + &c.ChantDoneToday, &c.QuoteDoneToday, + ); err != nil { + return nil, fmt.Errorf("scan candidate: %w", err) + } + out = append(out, c) + } + return out, rows.Err() +} + +// MarkChantReminded / MarkQuoteReminded stamp the UTC date a reminder went +// out, so the same kind won't fire again that day. +func (r *ReminderRepo) MarkChantReminded(ctx context.Context, userID string, utcDate time.Time) error { + _, err := r.pg.Pool.Exec(ctx, + `UPDATE users SET last_chant_reminder_on = $1 WHERE id = $2`, utcDate, userID) + return err +} + +func (r *ReminderRepo) MarkQuoteReminded(ctx context.Context, userID string, utcDate time.Time) error { + _, err := r.pg.Pool.Exec(ctx, + `UPDATE users SET last_quote_reminder_on = $1 WHERE id = $2`, utcDate, userID) + return err +} diff --git a/internal/repository/sadhna_repo.go b/internal/repository/sadhna_repo.go new file mode 100644 index 0000000..952686c --- /dev/null +++ b/internal/repository/sadhna_repo.go @@ -0,0 +1,67 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/prabhupadasays/spsays-bff/internal/model" +) + +// SadhnaRepo wraps Postgres access for sadhna_logs — the chanting half of +// a day's sādhana. The posting half lives in the posts table and is read +// via PostRepo.PostedDates, so this repo stays small. +type SadhnaRepo struct{ pg *Postgres } + +func NewSadhnaRepo(pg *Postgres) *SadhnaRepo { return &SadhnaRepo{pg: pg} } + +// UpsertToday records (or updates) the given day's chanting progress for +// the user. The caller passes the UTC calendar date explicitly (rather +// than relying on the DB's CURRENT_DATE) so day bucketing stays identical +// to PostedDates' `AT TIME ZONE 'UTC'`. chantDone is decided by the caller +// — rounds_completed >= the user's current target — and frozen into the +// row so later target changes don't rewrite past days. Returns the row. +func (r *SadhnaRepo) UpsertToday(ctx context.Context, userID string, logDate time.Time, rounds int, chantDone bool) (*model.SadhnaLog, error) { + var l model.SadhnaLog + err := r.pg.Pool.QueryRow(ctx, ` + INSERT INTO sadhna_logs (user_id, log_date, rounds_completed, chant_done) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, log_date) DO UPDATE + SET rounds_completed = EXCLUDED.rounds_completed, + chant_done = EXCLUDED.chant_done, + updated_at = now() + RETURNING id, user_id, log_date, rounds_completed, chant_done, created_at, updated_at`, + userID, logDate, rounds, chantDone, + ).Scan(&l.ID, &l.UserID, &l.LogDate, &l.RoundsCompleted, &l.ChantDone, &l.CreatedAt, &l.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("upsert sadhna log: %w", err) + } + return &l, nil +} + +// ListSince returns the user's sadhna logs on or after `since`, newest +// first. The window bounds the streak/growth computation; a few hundred +// rows at most. +func (r *SadhnaRepo) ListSince(ctx context.Context, userID string, since time.Time) ([]model.SadhnaLog, error) { + rows, err := r.pg.Pool.Query(ctx, ` + SELECT id, user_id, log_date, rounds_completed, chant_done, created_at, updated_at + FROM sadhna_logs + WHERE user_id = $1 AND log_date >= $2 + ORDER BY log_date DESC`, + userID, since, + ) + if err != nil { + return nil, fmt.Errorf("list sadhna logs: %w", err) + } + defer rows.Close() + + var out []model.SadhnaLog + for rows.Next() { + var l model.SadhnaLog + if err := rows.Scan(&l.ID, &l.UserID, &l.LogDate, &l.RoundsCompleted, &l.ChantDone, &l.CreatedAt, &l.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan sadhna log: %w", err) + } + out = append(out, l) + } + return out, rows.Err() +} diff --git a/internal/repository/sadhna_reward_repo.go b/internal/repository/sadhna_reward_repo.go new file mode 100644 index 0000000..1a6295b --- /dev/null +++ b/internal/repository/sadhna_reward_repo.go @@ -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 +} diff --git a/internal/repository/sadhna_rewards.json b/internal/repository/sadhna_rewards.json new file mode 100644 index 0000000..fefb765 --- /dev/null +++ b/internal/repository/sadhna_rewards.json @@ -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" + } + ] +} diff --git a/internal/server/server.go b/internal/server/server.go index 0add573..a28d969 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -33,6 +33,7 @@ type Server struct { // Worker lifecycle workerCancel context.CancelFunc + workerCtx context.Context // derived in initShortener; cancelled on Close // Handlers bookH *handler.BookHandler @@ -55,6 +56,9 @@ type Server struct { profileH *handler.ProfileHandler relationH *handler.RelationshipHandler notificationH *handler.NotificationHandler + sadhnaH *handler.SadhnaHandler + pushH *handler.PushHandler + reminderH *handler.ReminderHandler feedbackH *handler.FeedbackHandler statsH *handler.StatsHandler @@ -202,13 +206,42 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret, cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger, ) - notifSvc := service.NewNotificationService(notifRepo, userRepo, mail, rtClient, logger, cfg.FrontendURL) + pushRepo := repository.NewPushRepo(s.pg) + pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger) + s.pushH = handler.NewPushHandler(pushSender, pushRepo) + notifSvc := service.NewNotificationService(notifRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL) + + // Sādhana reminders: a background worker nudges users (email + push + + // websocket) to chant / post a quote at their local time. Shares the + // notification seam above so all three channels fire. + reminderRepo := repository.NewReminderRepo(s.pg) + reminderSvc := service.NewReminderService(reminderRepo, notifSvc, logger, cfg.FrontendURL) + s.reminderH = handler.NewReminderHandler(reminderSvc) + wctx := s.workerCtx + if wctx == nil { + wctx = context.Background() + } + worker.NewReminderWorker(reminderSvc, logger).Run(wctx) + graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger) s.relationH = handler.NewRelationshipHandler(graphSvc) s.notificationH = handler.NewNotificationHandler(notifSvc) s.postH = handler.NewPostHandler(postSvc, graphSvc) s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc) + // Sādhana streak: daily chanting log + the growing-plant state. Reads + // the chanting half from sadhna_logs and the posting half from the + // existing posts table, so it only needs its own repo plus PostRepo + + // UserRepo (for the rounds target). + sadhnaRepo := repository.NewSadhnaRepo(s.pg) + rewardRepo := repository.NewRewardRepo(s.pg) + // Sync the reward catalog from the embedded rewards.json (idempotent). + if err := rewardRepo.Seed(context.Background()); err != nil { + logger.Warn("sadhna: could not seed reward catalog", zap.Error(err)) + } + sadhnaSvc := service.NewSadhnaService(sadhnaRepo, postRepo, userRepo, rewardRepo, logger) + s.sadhnaH = handler.NewSadhnaHandler(sadhnaSvc) + // Feedback + stats — share Postgres with the rest of /api/v1 and // the existing mailer instance. feedbackRepo := repository.NewFeedbackRepo(s.pg) @@ -224,6 +257,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) { func (s *Server) initShortener(cfg *config.Config, logger *zap.Logger) { ctx, cancel := context.WithCancel(context.Background()) s.workerCancel = cancel + s.workerCtx = ctx pg, err := repository.NewPostgres(ctx, cfg.PostgresURI, logger) if err != nil { @@ -740,6 +774,38 @@ func (s *Server) setupRouter() { r.Get("/unread-count", s.notificationH.UnreadCount) r.Post("/read", s.notificationH.MarkAllRead) }) + + // --- Web Push --- VAPID key + per-user subscription management. + r.Route("/push", func(r chi.Router) { + if s.pushH == nil { + r.HandleFunc("/*", s.authUnavailable) + return + } + r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) + r.Get("/key", s.pushH.Key) + r.Post("/subscribe", s.pushH.Subscribe) + r.Post("/unsubscribe", s.pushH.Unsubscribe) + }) + + // --- Sādhana streak --- daily chanting log + growing-plant state. + // All endpoints require a signed-in user. + r.Route("/sadhna", func(r chi.Router) { + if s.sadhnaH == nil { + r.HandleFunc("/*", s.authUnavailable) + return + } + r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo)) + r.Get("/dashboard", s.sadhnaH.Dashboard) + r.Get("/history", s.sadhnaH.History) + r.Post("/chant", s.sadhnaH.SaveChant) + r.Get("/rewards", s.sadhnaH.Rewards) + r.Post("/rewards/ack", s.sadhnaH.AckRewards) + if s.reminderH != nil { + r.Get("/reminders", s.reminderH.GetPrefs) + r.Put("/reminders", s.reminderH.UpdatePrefs) + r.Post("/timezone", s.reminderH.SetTimezone) + } + }) }) s.router = r diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index 57a2a58..8461c20 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -29,6 +29,11 @@ import ( const ( NotifFollowRequest = "follow_request" NotifFollowAccepted = "follow_accepted" + + // Reminder kinds are delivered live (email + push + a websocket toast) + // but NOT persisted — they're ephemeral nudges, not history. + NotifChantReminder = "chant_reminder" + NotifQuoteReminder = "quote_reminder" ) type NotificationService struct { @@ -36,16 +41,18 @@ type NotificationService struct { users *repository.UserRepo mail mailer.Mailer rt *realtime.SoketiClient // optional — nil disables WebSocket push + push *PushSender // optional — nil disables Web Push logger *zap.Logger frontendURL string } -func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, logger *zap.Logger, frontendURL string) *NotificationService { +func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, push *PushSender, logger *zap.Logger, frontendURL string) *NotificationService { return &NotificationService{ notifs: notifs, users: users, mail: mail, rt: rt, + push: push, logger: logger, frontendURL: strings.TrimRight(frontendURL, "/"), } @@ -109,6 +116,10 @@ func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID // recipient has an email; authoritative data still comes from REST. s.publishRealtime(ctx, recipientID, kind, actorUser) + // Web Push (best-effort) — reaches the recipient even with the app + // closed. Same seam as realtime/email; nil sender is a no-op. + s.publishPush(ctx, recipientID, kind, actorUser) + // Email (best-effort). recipient, err := s.users.FindByID(ctx, recipientID) if err != nil { @@ -158,6 +169,73 @@ func (s *NotificationService) publishRealtime(ctx context.Context, recipientID, } } +// SendReminder delivers an ephemeral sādhana reminder across all live +// channels — a websocket toast (no unread bump, not persisted), a Web Push, +// and an email. Best-effort; nothing is persisted to the notification +// center. `url` is where tapping the notification lands. +func (s *NotificationService) SendReminder(ctx context.Context, userID, kind, title, body, url string) { + // Realtime toast — a distinct "reminder" event so the client toasts + // without bumping the unread badge (reminders have no DB row). + if s.rt != nil { + _ = s.rt.Trigger(ctx, "notifications."+userID, "reminder", map[string]any{ + "kind": kind, "title": title, "body": body, "url": url, + }) + } + + // Web Push (reaches closed apps). + if s.push != nil { + s.push.SendToUser(ctx, userID, PushPayload{Title: title, Body: body, URL: url, Tag: kind}) + } + + // Email (only if there's an address on file). + recipient, err := s.users.FindByID(ctx, userID) + if err != nil || recipient == nil || recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" { + return + } + var buf bytes.Buffer + if err := reminderEmailTpl.Execute(&buf, map[string]any{ + "RecipientName": recipient.Name, + "Message": body, + "URL": url, + }); err != nil { + s.logger.Warn("render reminder email failed", zap.String("kind", kind), zap.Error(err)) + return + } + if err := s.mail.Send(strings.TrimSpace(*recipient.Email), title, buf.String()); err != nil { + s.logger.Warn("reminder email send failed", zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err)) + } +} + +// publishPush delivers a Web Push notification to the recipient's devices. +// The body quotes the actor; tapping it opens their profile (where a pending +// follow request can be accepted). Best-effort; nil sender is a no-op. +func (s *NotificationService) publishPush(ctx context.Context, recipientID, kind string, actor *model.User) { + if s.push == nil { + return + } + who := "Someone" + url := s.frontendURL + "/notifications" + if actor != nil { + who = actorDisplay(actor) + url = s.frontendURL + "/u/" + actor.Handle + } + var body string + switch kind { + case NotifFollowRequest: + body = who + " requested to follow you" + case NotifFollowAccepted: + body = who + " accepted your follow request" + default: + body = "You have a new notification" + } + s.push.SendToUser(ctx, recipientID, PushPayload{ + Title: "Prabhupāda Says", + Body: body, + URL: url, + Tag: kind, + }) +} + func (s *NotificationService) render(kind string, recipient, actor *model.User) (string, error) { data := map[string]any{ "RecipientName": recipient.Name, @@ -221,3 +299,20 @@ var followAcceptedEmailTpl = template.Must(template.New("followAccepted").Parse(

Your servants,
The Prabhupāda Says team

`)) + +// 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(`
+

Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏

+ +

{{.Message}}

+ +

+ Open your sādhana +

+ +

You can turn these reminders off any time in Settings → Notifications.

+ +

Your servants,
+ The Prabhupāda Says team

+
`)) diff --git a/internal/service/push_sender.go b/internal/service/push_sender.go new file mode 100644 index 0000000..a50a001 --- /dev/null +++ b/internal/service/push_sender.go @@ -0,0 +1,98 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + + webpush "github.com/SherClockHolmes/webpush-go" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "go.uber.org/zap" +) + +// PushPayload is the JSON the service worker receives in its `push` event. +type PushPayload struct { + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url,omitempty"` + Icon string `json:"icon,omitempty"` + Tag string `json:"tag,omitempty"` +} + +// PushSender delivers Web Push messages via VAPID. Construct with +// NewPushSender; it returns nil when VAPID isn't configured, and every method +// is nil-safe so callers needn't branch. +type PushSender struct { + repo *repository.PushRepo + pub string + priv string + subject string + logger *zap.Logger +} + +// NewPushSender returns nil if the VAPID keys are absent (push disabled). +func NewPushSender(repo *repository.PushRepo, publicKey, privateKey, subject string, logger *zap.Logger) *PushSender { + if publicKey == "" || privateKey == "" || repo == nil { + return nil + } + return &PushSender{repo: repo, pub: publicKey, priv: privateKey, subject: subject, logger: logger} +} + +// PublicKey is the VAPID application server key the browser subscribes with. +func (p *PushSender) PublicKey() string { + if p == nil { + return "" + } + return p.pub +} + +// Enabled reports whether push delivery is configured. +func (p *PushSender) Enabled() bool { return p != nil } + +// SendToUser pushes a payload to every subscription the user has, pruning any +// endpoint the push service reports as gone (404/410). Best-effort: failures +// are logged, never returned. +func (p *PushSender) SendToUser(ctx context.Context, userID string, payload PushPayload) { + if p == nil { + return + } + subs, err := p.repo.ListByUser(ctx, userID) + if err != nil { + p.logger.Warn("push: list subscriptions failed", zap.String("user", userID), zap.Error(err)) + return + } + if len(subs) == 0 { + return + } + body, err := json.Marshal(payload) + if err != nil { + return + } + for _, s := range subs { + sub := &webpush.Subscription{ + Endpoint: s.Endpoint, + Keys: webpush.Keys{P256dh: s.P256dh, Auth: s.Auth}, + } + resp, err := webpush.SendNotification(body, sub, &webpush.Options{ + Subscriber: p.subject, + VAPIDPublicKey: p.pub, + VAPIDPrivateKey: p.priv, + TTL: 60, + Urgency: webpush.UrgencyNormal, + }) + if err != nil { + p.logger.Warn("push: send failed", zap.String("user", userID), zap.Error(err)) + continue + } + status := resp.StatusCode + _ = resp.Body.Close() + // Subscription expired/gone — drop it so we stop trying. + if status == http.StatusNotFound || status == http.StatusGone { + if derr := p.repo.DeleteByEndpoint(ctx, s.Endpoint); derr != nil { + p.logger.Warn("push: prune failed", zap.Error(derr)) + } + } else if status >= 400 { + p.logger.Warn("push: non-2xx", zap.Int("status", status), zap.String("user", userID)) + } + } +} diff --git a/internal/service/reminder_service.go b/internal/service/reminder_service.go new file mode 100644 index 0000000..e7d5378 --- /dev/null +++ b/internal/service/reminder_service.go @@ -0,0 +1,171 @@ +// Package service — ReminderService drives the daily sādhana reminders. +// +// A worker calls RunOnce on a fixed tick. For each reminder-enabled user it +// computes the user's *local* clock time (from their IANA timezone) and, if +// it falls in the send window for that reminder AND they haven't done the +// discipline today AND they haven't already been reminded today, sends the +// nudge through every channel (email + push + websocket toast). +// +// "Done today" and the once-per-day dedupe are bucketed by UTC calendar day, +// matching the streak logic; only the *time of day* the reminder fires is +// local. The recency-aware chant message is chosen from the user's most +// recent completed chant day. +package service + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + // Embed the IANA tz database so time.LoadLocation works regardless of + // whether the host image ships zoneinfo. + _ "time/tzdata" + + "github.com/prabhupadasays/spsays-bff/internal/repository" + "go.uber.org/zap" +) + +// ErrInvalidTimezone is returned when a timezone-set request carries a value +// that isn't a loadable IANA zone. +var ErrInvalidTimezone = errors.New("invalid timezone") + +const ( + // reminderWindowMin must be >= the worker tick so no target time is + // missed between ticks; the per-day dedupe prevents a double-send within + // the overlap. + reminderWindowMin = 20 + + chantMsgStart = "Chant with Prabhupāda — start building your sādhana streak." + chantMsgLapsed = "Just one hour of sādhana can save you from this duḥkhālayam aśāśvatam. Come and build your streak again." + chantMsgYesterday = "Your sādhana was wonderful yesterday! Let's continue your sādhana today as well." + quoteMsg = "Share a verse from the Vāṇī and complete today's sādhana." +) + +type ReminderService struct { + repo *repository.ReminderRepo + notif *NotificationService + logger *zap.Logger + sadhnaURL string +} + +func NewReminderService(repo *repository.ReminderRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService { + return &ReminderService{ + repo: repo, + notif: notif, + logger: logger, + sadhnaURL: strings.TrimRight(frontendURL, "/") + "/sadhna-streak", + } +} + +// Enabled / SetEnabled / SetTimezone back the settings + tz-capture endpoints. +func (s *ReminderService) Enabled(ctx context.Context, userID string) (bool, error) { + return s.repo.GetEnabled(ctx, userID) +} + +func (s *ReminderService) SetEnabled(ctx context.Context, userID string, enabled bool) error { + return s.repo.SetEnabled(ctx, userID, enabled) +} + +// SetTimezone validates the IANA zone before storing so a bad value can never +// break the (timezone-using) candidate scan. +func (s *ReminderService) SetTimezone(ctx context.Context, userID, tz string) error { + tz = strings.TrimSpace(tz) + if tz == "" { + tz = "UTC" + } + if _, err := time.LoadLocation(tz); err != nil { + return ErrInvalidTimezone + } + return s.repo.SetTimezone(ctx, userID, tz) +} + +// RunOnce evaluates every due candidate against `now` and fires the reminders +// that are due. Errors are logged, never returned — a bad row shouldn't stop +// the rest. +func (s *ReminderService) RunOnce(ctx context.Context, now time.Time) { + candidates, err := s.repo.DueCandidates(ctx) + if err != nil { + s.logger.Warn("reminders: load candidates failed", zap.Error(err)) + return + } + utc := now.UTC() + utcToday := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC) + + for _, c := range candidates { + loc, err := time.LoadLocation(c.TZ) + if err != nil { + loc = time.UTC + } + local := now.In(loc) + localMin := local.Hour()*60 + local.Minute() + + // Chant reminder. + if !c.ChantDoneToday && notRemindedToday(c.LastChantOn, utcToday) { + if t := parseHHMM(c.ChantTime); t >= 0 && withinWindow(localMin, t) { + s.notif.SendReminder(ctx, c.UserID, NotifChantReminder, + "Time for your sādhana 🙏", chantMessage(c.LastChantDate, utcToday), s.sadhnaURL) + if err := s.repo.MarkChantReminded(ctx, c.UserID, utcToday); err != nil { + s.logger.Warn("reminders: mark chant failed", zap.String("user", c.UserID), zap.Error(err)) + } + } + } + + // Quote reminder. + if !c.QuoteDoneToday && notRemindedToday(c.LastQuoteOn, utcToday) { + if t := parseHHMM(c.QuoteTime); t >= 0 && withinWindow(localMin, t) { + s.notif.SendReminder(ctx, c.UserID, NotifQuoteReminder, + "Share a verse from the Vāṇī", quoteMsg, s.sadhnaURL) + if err := s.repo.MarkQuoteReminded(ctx, c.UserID, utcToday); err != nil { + s.logger.Warn("reminders: mark quote failed", zap.String("user", c.UserID), zap.Error(err)) + } + } + } + } +} + +// chantMessage picks the recency-aware nudge from the most recent completed +// chant day (both args are UTC midnight). The candidate is, by construction, +// not done today, so the gap is >= 1 day. +func chantMessage(lastChant *time.Time, utcToday time.Time) string { + if lastChant == nil { + return chantMsgStart + } + days := int(utcToday.Sub(*lastChant).Hours() / 24) + switch { + case days <= 1: + return chantMsgYesterday // chanted yesterday + case days <= 7: + return chantMsgLapsed // within the last week, but not yesterday + default: + return chantMsgStart // nothing in over a week + } +} + +// notRemindedToday is true when no reminder of this kind has gone out on the +// current UTC day. +func notRemindedToday(last *time.Time, utcToday time.Time) bool { + return last == nil || last.Before(utcToday) +} + +// withinWindow reports whether localMin falls in [target, target+window), +// handling midnight wrap. +func withinWindow(localMin, target int) bool { + diff := ((localMin-target)%1440 + 1440) % 1440 + return diff < reminderWindowMin +} + +// parseHHMM turns "HH:MM" into minutes-of-day, or -1 if malformed. +func parseHHMM(s string) int { + parts := strings.SplitN(strings.TrimSpace(s), ":", 2) + if len(parts) != 2 { + return -1 + } + h, err1 := strconv.Atoi(parts[0]) + m, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 { + return -1 + } + return h*60 + m +} diff --git a/internal/service/sadhna_service.go b/internal/service/sadhna_service.go new file mode 100644 index 0000000..7c573cd --- /dev/null +++ b/internal/service/sadhna_service.go @@ -0,0 +1,418 @@ +// Package service — SadhnaService is the sādhana-streak brain. It folds a +// user's chanting logs (sadhna_logs) together with their quote posts +// (posts table) into the single dashboard the /sadhna-streak page reads: +// today's two disciplines, the trailing 7-day strip, the streak counters, +// and the growing-plant state. +// +// A day is "complete" only when BOTH daily disciplines were done that UTC +// calendar day: the target rounds were chanted AND at least one quote was +// posted. The streak counts consecutive complete days; the plant's growth +// is a 0–100 score that rises a little each complete day and falls faster +// for each missed day, eventually decaying to 0 ("dead") — and revives as +// soon as the practice resumes. The whole thing is recomputed from the +// underlying rows on every read, so there's no derived state to keep in +// sync and back-dated edits can never drift. +package service + +import ( + "context" + "math/rand" + "time" + + "github.com/prabhupadasays/spsays-bff/internal/model" + "github.com/prabhupadasays/spsays-bff/internal/repository" + "go.uber.org/zap" +) + +// Tunables for the streak + plant. Kept together so the growth feel is +// easy to adjust in one place. +const ( + // defaultChantTarget is the fallback daily rounds when a user hasn't + // set daily_chanting_rounds on their profile (Śrīla Prabhupāda's + // minimum for initiated devotees). + defaultChantTarget = 16 + + // growthMax is the score at which the plant is a full mature tree. + growthMax = 100 + // growthPerDay is added for each complete day (≈25 perfect days from + // seed to full tree). + growthPerDay = 4 + // regressPerDay is subtracted for each missed day. Deliberately larger + // than growthPerDay so neglect undoes progress faster than practice + // builds it (a full tree withers to nothing in ~12 missed days). + regressPerDay = 9 + + // streakWindowDays bounds how far back the recompute walks. Comfortably + // longer than any growth/decay can persist, so the [0,100] clamp washes + // out the unknown state before the window's start. + streakWindowDays = 400 + + // vineMaxDays caps how many folds the creeper payload carries. The + // client only renders as many as fit the pole anyway; CurrentStreak + // still reports the true length. + vineMaxDays = 60 +) + +// rewardEveryDays is the streak cadence for gifts: one reward every 7 +// completed days, counted from the start of the current unbroken streak +// (days 7, 14, 21, …). A broken streak restarts the count. +const rewardEveryDays = 7 + +// SadhnaService computes streak/plant state and records chanting progress. +type SadhnaService struct { + logs *repository.SadhnaRepo + posts *repository.PostRepo + users *repository.UserRepo + rewards *repository.RewardRepo + logger *zap.Logger +} + +func NewSadhnaService( + logs *repository.SadhnaRepo, + posts *repository.PostRepo, + users *repository.UserRepo, + rewards *repository.RewardRepo, + logger *zap.Logger, +) *SadhnaService { + return &SadhnaService{logs: logs, posts: posts, users: users, rewards: rewards, logger: logger} +} + +const dayFmt = "2006-01-02" + +// utcToday returns today's UTC calendar date truncated to midnight, the +// anchor every day-bucket comparison uses. +func utcToday() time.Time { + now := time.Now().UTC() + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) +} + +// SaveChant records the user's chanting progress for today and returns the +// freshly recomputed dashboard. Rounds outside [0,1008] are clamped. +// chant_done is frozen as rounds >= the user's current target. +func (s *SadhnaService) SaveChant(ctx context.Context, userID string, rounds int) (*model.SadhnaDashboard, error) { + if rounds < 0 { + rounds = 0 + } + if rounds > 1008 { + rounds = 1008 + } + target := s.targetFor(ctx, userID) + chantDone := rounds >= target + + if _, err := s.logs.UpsertToday(ctx, userID, utcToday(), rounds, chantDone); err != nil { + return nil, err + } + return s.Dashboard(ctx, userID) +} + +// Dashboard recomputes the user's full sādhana state from their chanting +// logs and quote posts over the trailing window. +func (s *SadhnaService) Dashboard(ctx context.Context, userID string) (*model.SadhnaDashboard, error) { + target := s.targetFor(ctx, userID) + today := utcToday() + since := today.AddDate(0, 0, -(streakWindowDays - 1)) + + logs, err := s.logs.ListSince(ctx, userID, since) + if err != nil { + return nil, err + } + postsByDay, err := s.posts.PostCountsByDate(ctx, userID, since) + if err != nil { + return nil, err + } + + // Index the chanting half by day. + chantByDay := make(map[string]bool, len(logs)) + roundsByDay := make(map[string]int, len(logs)) + for _, l := range logs { + key := l.LogDate.UTC().Format(dayFmt) + chantByDay[key] = l.ChantDone + roundsByDay[key] = l.RoundsCompleted + } + + // posted[day] = at least one quote that day; postsByDay[day] = how many. + posted := func(day string) bool { return postsByDay[day] > 0 } + // completed[day] = both disciplines done that day. + completed := func(day string) bool { return chantByDay[day] && posted(day) } + + todayKey := today.Format(dayFmt) + + // --- Growth: forward-simulate the window, day by day. Today never + // regresses the plant (the day isn't over yet) — it only adds growth + // once complete. --- + growth := 0 + for d := since; !d.After(today); d = d.AddDate(0, 0, 1) { + key := d.Format(dayFmt) + isToday := key == todayKey + switch { + case completed(key): + growth += growthPerDay + if growth > growthMax { + growth = growthMax + } + case isToday: + // in-progress day — leave growth as-is + default: + growth -= regressPerDay + if growth < 0 { + growth = 0 + } + } + } + + // --- Streaks. Current streak ends at today if today is complete, + // otherwise at yesterday (so an unfinished today doesn't read as a + // broken streak mid-day). --- + current := 0 + cursor := today + if !completed(todayKey) { + cursor = today.AddDate(0, 0, -1) + } + for !cursor.Before(since) && completed(cursor.Format(dayFmt)) { + current++ + cursor = cursor.AddDate(0, 0, -1) + } + + // Longest run of complete days anywhere in the window. + longest, run := 0, 0 + for d := since; !d.After(today); d = d.AddDate(0, 0, 1) { + if completed(d.Format(dayFmt)) { + run++ + if run > longest { + longest = run + } + } else { + run = 0 + } + } + if current > longest { + longest = current + } + + // --- The trailing 7-day strip, oldest first. --- + week := make([]model.SadhnaDay, 0, 7) + for i := 6; i >= 0; i-- { + d := today.AddDate(0, 0, -i) + key := d.Format(dayFmt) + week = append(week, model.SadhnaDay{ + Date: key, + ChantDone: chantByDay[key], + QuoteDone: posted(key), + Completed: completed(key), + }) + } + + // --- The creeper: one fold per day of the current streak (oldest + // first), plus a final in-progress fold for an unfinished today. The + // streak's complete days end at today when today is complete, else at + // yesterday; an unfinished today is appended as the growing tip. --- + vine := make([]model.VineDay, 0, current+1) + if current >= 1 { + end := today + if !completed(todayKey) { + end = today.AddDate(0, 0, -1) + } + start := end.AddDate(0, 0, -(current - 1)) + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + key := d.Format(dayFmt) + vine = append(vine, model.VineDay{ + Date: key, + Rounds: roundsByDay[key], + Posts: postsByDay[key], + Completed: true, + }) + } + } + if !completed(todayKey) { + vine = append(vine, model.VineDay{ + Date: todayKey, + Rounds: roundsByDay[todayKey], + Posts: postsByDay[todayKey], + InProgress: true, + }) + } + if len(vine) > vineMaxDays { + vine = vine[len(vine)-vineMaxDays:] + } + + // --- Rewards: a gift every 7 streak-days. When today completes a + // multiple-of-7 streak day, grant one (idempotently). Surface any + // not-yet-celebrated grant + the projected date of the next gift. --- + var pending *model.SadhnaRewardGrant + var nextReward *string + if s.rewards != nil { + todayDone := completed(todayKey) + if todayDone && current > 0 && current%rewardEveryDays == 0 { + s.ensureGrant(ctx, userID, current) + } + pending, _ = s.rewards.PendingGrant(ctx, userID) + nextReward = nextRewardDate(today, current, todayDone) + } + + return &model.SadhnaDashboard{ + TargetRounds: target, + RoundsCompleted: roundsByDay[todayKey], + ChantDone: chantByDay[todayKey], + QuoteDone: posted(todayKey), + TodayCompleted: completed(todayKey), + CurrentStreak: current, + LongestStreak: longest, + Growth: growth, + Stage: stageFor(growth), + Alive: growth > 0, + Week: week, + VineDays: vine, + NextRewardDate: nextReward, + PendingReward: pending, + }, nil +} + +// ensureGrant idempotently awards a gift for the given streak milestone, +// picking a reward the user hasn't received yet. Once every reward has been +// collected the pool resets (avoiding an immediate back-to-back repeat) so +// the gifts cycle. Best-effort: any error leaves the milestone ungranted, +// to be retried on the next recompute. +func (s *SadhnaService) ensureGrant(ctx context.Context, userID string, milestone int) { + has, err := s.rewards.HasMilestone(ctx, userID, milestone) + if err != nil || has { + return + } + pool, err := s.rewards.ListGiftable(ctx) + if err != nil || len(pool) == 0 { + return + } + granted, err := s.rewards.GrantedRewardIDs(ctx, userID) + if err != nil { + return + } + + // Prefer rewards not yet received. + cand := make([]model.SadhnaReward, 0, len(pool)) + for _, r := range pool { + if !granted[r.ID] { + cand = append(cand, r) + } + } + // All collected → cycle, excluding the most recent to avoid a repeat. + if len(cand) == 0 { + last, _ := s.rewards.LastGrantedRewardID(ctx, userID) + for _, r := range pool { + if r.ID != last { + cand = append(cand, r) + } + } + if len(cand) == 0 { + cand = pool + } + } + + pick := cand[rand.Intn(len(cand))] + if err := s.rewards.CreateGrant(ctx, userID, pick.ID, milestone); err != nil { + s.logger.Warn("sadhna: could not grant reward", zap.Error(err)) + } +} + +// nextRewardDate projects the UTC date the next gift lands if the streak +// continues unbroken — the date the streak count next reaches a multiple of +// rewardEveryDays. An unfinished today is assumed to be completed. +func nextRewardDate(today time.Time, current int, todayCompleted bool) *string { + next := (current/rewardEveryDays + 1) * rewardEveryDays // first multiple > current + days := next - current + if !todayCompleted { + // today isn't counted yet; completing it is the first of the + // remaining days, so it lands one day sooner. + days-- + } + if days < 0 { + days = 0 + } + d := today.AddDate(0, 0, days).Format(dayFmt) + return &d +} + +// Rewards returns the user's collected gifts (newest first) and the size of +// the giftable catalog (for "N of M" framing on the client). +func (s *SadhnaService) Rewards(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, int, error) { + grants, err := s.rewards.GrantsByUser(ctx, userID) + if err != nil { + return nil, 0, err + } + pool, err := s.rewards.ListGiftable(ctx) + if err != nil { + return nil, 0, err + } + return grants, len(pool), nil +} + +// AckRewards clears the user's pending celebration(s) after the client has +// shown the confetti. +func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error { + return s.rewards.MarkCelebrated(ctx, userID) +} + +// History returns the per-day sādhana state for every UTC calendar day in +// the inclusive range [from, to] — what the calendar needs to render an +// arbitrary week or month. Days with no activity (including future days) +// come back as all-false. The caller is expected to bound the range. +func (s *SadhnaService) History(ctx context.Context, userID string, from, to time.Time) ([]model.SadhnaDay, error) { + logs, err := s.logs.ListSince(ctx, userID, from) + if err != nil { + return nil, err + } + postsByDay, err := s.posts.PostCountsByDate(ctx, userID, from) + if err != nil { + return nil, err + } + + chantByDay := make(map[string]bool, len(logs)) + for _, l := range logs { + chantByDay[l.LogDate.UTC().Format(dayFmt)] = l.ChantDone + } + + out := make([]model.SadhnaDay, 0, 42) + for d := from; !d.After(to); d = d.AddDate(0, 0, 1) { + key := d.Format(dayFmt) + quote := postsByDay[key] > 0 + out = append(out, model.SadhnaDay{ + Date: key, + ChantDone: chantByDay[key], + QuoteDone: quote, + Completed: chantByDay[key] && quote, + }) + } + return out, nil +} + +// targetFor returns the user's daily rounds goal, falling back to the +// default when unset/out of range or when the user can't be loaded (the +// dashboard should still render). +func (s *SadhnaService) targetFor(ctx context.Context, userID string) int { + u, err := s.users.FindByID(ctx, userID) + if err != nil || u == nil || u.DailyChantingRounds == nil { + return defaultChantTarget + } + if r := *u.DailyChantingRounds; r >= 1 && r <= 1008 { + return r + } + return defaultChantTarget +} + +// stageFor maps a growth score to the plant's visual stage. The client +// renders the matching artwork and interpolates within the stage by the +// raw growth value. +func stageFor(growth int) string { + switch { + case growth <= 0: + return "dead" + case growth <= 15: + return "sprout" + case growth <= 35: + return "seedling" + case growth <= 60: + return "sapling" + case growth <= 85: + return "young" + default: + return "mature" + } +} diff --git a/internal/worker/reminder_worker.go b/internal/worker/reminder_worker.go new file mode 100644 index 0000000..c1884b7 --- /dev/null +++ b/internal/worker/reminder_worker.go @@ -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()) + } + } + }() +} diff --git a/migrations/0025_create_sadhna_logs.down.sql b/migrations/0025_create_sadhna_logs.down.sql new file mode 100644 index 0000000..9b9bfd5 --- /dev/null +++ b/migrations/0025_create_sadhna_logs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS sadhna_logs; diff --git a/migrations/0025_create_sadhna_logs.up.sql b/migrations/0025_create_sadhna_logs.up.sql new file mode 100644 index 0000000..981fc42 --- /dev/null +++ b/migrations/0025_create_sadhna_logs.up.sql @@ -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); diff --git a/migrations/0026_create_sadhna_rewards.down.sql b/migrations/0026_create_sadhna_rewards.down.sql new file mode 100644 index 0000000..a7e2755 --- /dev/null +++ b/migrations/0026_create_sadhna_rewards.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS sadhna_reward_grants; +DROP TABLE IF EXISTS sadhna_rewards; diff --git a/migrations/0026_create_sadhna_rewards.up.sql b/migrations/0026_create_sadhna_rewards.up.sql new file mode 100644 index 0000000..ce080b9 --- /dev/null +++ b/migrations/0026_create_sadhna_rewards.up.sql @@ -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); diff --git a/migrations/0027_create_push_subscriptions.down.sql b/migrations/0027_create_push_subscriptions.down.sql new file mode 100644 index 0000000..e923987 --- /dev/null +++ b/migrations/0027_create_push_subscriptions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS push_subscriptions; diff --git a/migrations/0027_create_push_subscriptions.up.sql b/migrations/0027_create_push_subscriptions.up.sql new file mode 100644 index 0000000..145d3e3 --- /dev/null +++ b/migrations/0027_create_push_subscriptions.up.sql @@ -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); diff --git a/migrations/0028_add_reminder_fields.down.sql b/migrations/0028_add_reminder_fields.down.sql new file mode 100644 index 0000000..ae120db --- /dev/null +++ b/migrations/0028_add_reminder_fields.down.sql @@ -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; diff --git a/migrations/0028_add_reminder_fields.up.sql b/migrations/0028_add_reminder_fields.up.sql new file mode 100644 index 0000000..7575a77 --- /dev/null +++ b/migrations/0028_add_reminder_fields.up.sql @@ -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;