Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a49bf7591e | ||
|
|
aa61f4f0b1 | ||
|
|
bbe491ae13 | ||
|
|
438258ade0 | ||
|
|
93e92c610c | ||
|
|
4946ce1062 | ||
|
|
025c83eec5 | ||
|
|
c67a657ca2 | ||
|
|
2afbbfa0f2 | ||
|
|
8a2bdf945a | ||
|
|
2135ec334c | ||
|
|
05567ad438 | ||
|
|
6ebaa36158 | ||
|
|
1edee92865 | ||
|
|
0b24ab4b45 | ||
|
|
0655b2314d | ||
|
|
187bcd1442 | ||
|
|
cb62adf2de | ||
|
|
d86d876ac1 | ||
|
|
e9299051f5 | ||
|
|
da2bbc323d | ||
|
|
0188a34cf6 | ||
|
|
0d189739d6 | ||
|
|
6c61e97bba | ||
|
|
1f02ae26f6 | ||
|
|
29c5f7cee5 | ||
|
|
e79f347197 | ||
|
|
26f0e298b5 | ||
|
|
f2a04ae652 | ||
|
|
05d906aee9 | ||
|
|
0c8b2a54b3 | ||
|
|
3902033508 | ||
|
|
39450c3266 | ||
|
|
f598770b32 | ||
|
|
e352948fb1 | ||
|
|
efa1eaf23b | ||
|
|
d9678ebb3b | ||
|
|
e9a96f1ff9 | ||
|
|
61b91c473b | ||
|
|
7f5b0fee11 | ||
|
|
3f6b7b6636 | ||
|
|
cf3a49458a | ||
|
|
ac95a857bd | ||
|
|
b895ceaccf | ||
|
|
59b3687e43 | ||
|
|
0a348593b1 | ||
|
|
72f4e6527c | ||
|
|
d3ad443fde | ||
|
|
0969887008 | ||
|
|
05b61cef09 | ||
|
|
430747536d |
+24
-1
@@ -25,13 +25,18 @@ R2_BUCKET=
|
||||
# Public base URL where bucket objects are served (custom domain or pub-XXX.r2.dev)
|
||||
R2_PUBLIC_URL=
|
||||
|
||||
# SMTP (password-reset + email-verification)
|
||||
# SMTP (password-reset + email-verification + /feedback admin notifications)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM="Prabhupāda Says <noreply@prabhupadasays.org>"
|
||||
|
||||
# Comma-separated recipients for /feedback admin notifications. Empty
|
||||
# disables the email fan-out (feedback rows still persist and show on
|
||||
# the /stats page either way).
|
||||
FEEDBACK_ADMIN_EMAILS=
|
||||
|
||||
# Push Notifications
|
||||
FCM_CREDENTIALS_FILE=
|
||||
APNS_KEY_FILE=
|
||||
@@ -48,3 +53,21 @@ COOKIE_SECURE=false
|
||||
|
||||
# Frontend (CORS + email link base)
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
FACEBOOK_OAUTH_CLIENT_ID=
|
||||
FACEBOOK_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# SOKETI
|
||||
SOKETI_APP_ID=
|
||||
SOKETI_APP_KEY=
|
||||
SOKETI_APP_SECRET=
|
||||
SOKETI_HOST=ws.prabhupadasays.org
|
||||
SOKETI_PORT=443
|
||||
SOKETI_USE_TLS=true
|
||||
|
||||
# Web Push
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=
|
||||
+33
-7
@@ -12,8 +12,39 @@ import (
|
||||
"github.com/prabhupadasays/spsays-bff/internal/config"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/server"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// newLogger builds the app logger, routing logs by level: Debug/Info/Warn go
|
||||
// to stdout and only Error/Fatal/Panic go to stderr. Hosting platforms tag
|
||||
// every stderr line as "error", so without this split a routine Debug/Info
|
||||
// line shows up with a misleading error badge. Production uses JSON at Info+;
|
||||
// development uses colored console at Debug+.
|
||||
func newLogger(production bool) *zap.Logger {
|
||||
var encoder zapcore.Encoder
|
||||
minLevel := zapcore.DebugLevel
|
||||
if production {
|
||||
encoder = zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig())
|
||||
minLevel = zapcore.InfoLevel
|
||||
} else {
|
||||
encCfg := zap.NewDevelopmentEncoderConfig()
|
||||
encCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
encoder = zapcore.NewConsoleEncoder(encCfg)
|
||||
}
|
||||
|
||||
low := zap.LevelEnablerFunc(func(l zapcore.Level) bool {
|
||||
return l >= minLevel && l < zapcore.ErrorLevel
|
||||
})
|
||||
high := zap.LevelEnablerFunc(func(l zapcore.Level) bool {
|
||||
return l >= zapcore.ErrorLevel
|
||||
})
|
||||
core := zapcore.NewTee(
|
||||
zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), low),
|
||||
zapcore.NewCore(encoder, zapcore.Lock(os.Stderr), high),
|
||||
)
|
||||
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Load config
|
||||
cfg, err := config.Load()
|
||||
@@ -22,13 +53,8 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize logger
|
||||
var logger *zap.Logger
|
||||
if cfg.Env == "production" {
|
||||
logger, _ = zap.NewProduction()
|
||||
} else {
|
||||
logger, _ = zap.NewDevelopment()
|
||||
}
|
||||
// Initialize logger — routes normal logs to stdout, errors to stderr.
|
||||
logger := newLogger(cfg.Env == "production")
|
||||
defer logger.Sync()
|
||||
|
||||
sugar := logger.Sugar()
|
||||
|
||||
+52
-10
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/config"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/revalidate"
|
||||
"github.com/typesense/typesense-go/v2/typesense"
|
||||
"github.com/typesense/typesense-go/v2/typesense/api"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
var (
|
||||
fullReindex = flag.Bool("full", false, "Drop all collections and reindex everything")
|
||||
onlyFlag = flag.String("only", "", "Comma-separated list of collections to index (e.g., verses,letters)")
|
||||
noDrop = flag.Bool("no-drop", false, "Do not drop the collection first — upsert into the existing one (resume a partial import)")
|
||||
)
|
||||
|
||||
// All supported collection names
|
||||
@@ -146,8 +148,11 @@ func main() {
|
||||
for _, name := range collectionsToIndex {
|
||||
sugar.Infof("=== Processing collection: %s ===", name)
|
||||
|
||||
shouldDrop := *fullReindex || *onlyFlag != ""
|
||||
if !shouldDrop && existingCollections[name] {
|
||||
shouldDrop := (*fullReindex || *onlyFlag != "") && !*noDrop
|
||||
// On a bare run (no targeting), skip collections that already exist.
|
||||
// An explicit --full / --only / --no-drop always (re)processes them.
|
||||
targeted := *fullReindex || *onlyFlag != "" || *noDrop
|
||||
if !targeted && existingCollections[name] {
|
||||
sugar.Infof("[%s] Collection already exists, skipping (use --full to force reindex)", name)
|
||||
continue
|
||||
}
|
||||
@@ -187,23 +192,46 @@ func main() {
|
||||
}
|
||||
|
||||
sugar.Info("=== Indexing complete ===")
|
||||
|
||||
// Content changed → refresh the frontend sitemap cache (synchronous: this
|
||||
// CLI exits right after, so a background goroutine wouldn't run). No-op
|
||||
// when FRONTEND_URL / REVALIDATE_SECRET are unset.
|
||||
if err := revalidate.New(cfg.FrontendURL, cfg.RevalidateSecret, logger).TriggerSync("sitemap"); err != nil {
|
||||
sugar.Warnf("sitemap revalidation ping failed: %v", err)
|
||||
} else if cfg.RevalidateSecret != "" {
|
||||
sugar.Info("sitemap revalidation pinged")
|
||||
}
|
||||
}
|
||||
|
||||
// importBatch imports documents into Typesense in batches of batchSize.
|
||||
func importBatch(sugar *zap.SugaredLogger, tsClient *typesense.Client, collectionName string, docs []interface{}, batchSize int) {
|
||||
total := len(docs)
|
||||
action := "create"
|
||||
// Upsert (not create) so a re-run over an existing collection is
|
||||
// idempotent and additive — essential for resuming a partial import
|
||||
// when a flaky link drops batches mid-run.
|
||||
action := "upsert"
|
||||
for i := 0; i < total; i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
batch := docs[i:end]
|
||||
responses, err := tsClient.Collection(collectionName).Documents().Import(context.Background(), batch, &api.ImportDocumentsParams{
|
||||
// Retry the batch on transient transport errors (flaky networks can
|
||||
// drop large TLS writes — "broken pipe" / "bad record MAC").
|
||||
var responses []*api.ImportDocumentResponse
|
||||
var err error
|
||||
for attempt := 1; attempt <= 8; attempt++ {
|
||||
responses, err = tsClient.Collection(collectionName).Documents().Import(context.Background(), batch, &api.ImportDocumentsParams{
|
||||
Action: &action,
|
||||
})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
sugar.Warnf("[%s] batch %d-%d attempt %d/5 failed: %v", collectionName, i, end, attempt, err)
|
||||
time.Sleep(time.Duration(attempt) * time.Second)
|
||||
}
|
||||
if err != nil {
|
||||
sugar.Errorf("[%s] Import error at batch %d-%d: %v", collectionName, i, end, err)
|
||||
sugar.Errorf("[%s] Import error at batch %d-%d (gave up): %v", collectionName, i, end, err)
|
||||
continue
|
||||
}
|
||||
// Check for per-document errors
|
||||
@@ -371,9 +399,12 @@ func indexProse(sugar *zap.SugaredLogger, tsClient *typesense.Client, mongoDB *r
|
||||
|
||||
_, err := tsClient.Collections().Create(context.Background(), schema)
|
||||
if err != nil {
|
||||
sugar.Fatalf("[prose] Failed to create collection: %v", err)
|
||||
}
|
||||
// An already-existing collection is fine: with --no-drop we resume an
|
||||
// upsert into it (the schema is unchanged).
|
||||
sugar.Warnf("[prose] Create collection: %v (continuing — assuming it exists)", err)
|
||||
} else {
|
||||
sugar.Info("[prose] Collection created")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
@@ -406,13 +437,22 @@ func indexProse(sugar *zap.SugaredLogger, tsClient *typesense.Client, mongoDB *r
|
||||
content = joinParagraphs(strs)
|
||||
}
|
||||
|
||||
// Build URL: KB uses /books/kb/{volume}/{slug}, others use /books/{book}/{slug}
|
||||
// Build URL: KB uses /books/kb/{volume}/{slug}; chapter summaries map
|
||||
// their "<lila>-<n>" slug to the chapter landing page; others use
|
||||
// /books/{book}/{slug}.
|
||||
var proseURL string
|
||||
if book == "kb" {
|
||||
switch {
|
||||
case book == "kb":
|
||||
proseURL = fmt.Sprintf("/books/kb/%d/%s", volume, slug)
|
||||
case docType == "chapter-summary":
|
||||
if parts := strings.SplitN(slug, "-", 2); len(parts) == 2 {
|
||||
proseURL = fmt.Sprintf("/books/%s/%s/%s", book, parts[0], parts[1])
|
||||
} else {
|
||||
proseURL = fmt.Sprintf("/books/%s/%s", book, slug)
|
||||
}
|
||||
default:
|
||||
proseURL = fmt.Sprintf("/books/%s/%s", book, slug)
|
||||
}
|
||||
|
||||
doc := map[string]interface{}{
|
||||
"id": id,
|
||||
@@ -428,7 +468,9 @@ func indexProse(sugar *zap.SugaredLogger, tsClient *typesense.Client, mongoDB *r
|
||||
}
|
||||
|
||||
sugar.Infof("[prose] Read %d documents from MongoDB", len(docs))
|
||||
importBatch(sugar, tsClient, "prose", docs, 500)
|
||||
// Small batches keep each import POST modest in size — large multi-MB
|
||||
// bodies (front-matter intro segments) are prone to drops on flaky links.
|
||||
importBatch(sugar, tsClient, "prose", docs, 10)
|
||||
}
|
||||
|
||||
// ==================== LETTERS ====================
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Command seed-book-intro seeds a book's front-matter (intro-segments such as
|
||||
// Dedication, Front Flap, Preface, Introduction, Disciplic Succession) into the
|
||||
// `prose_chapters` MongoDB collection (volume=0, type="intro-segment").
|
||||
//
|
||||
// The segment JSON is produced by the per-book extractor scripts, e.g.
|
||||
// extractor/scripts/bg_intro_extract.py, cc_intro_extract.py.
|
||||
//
|
||||
// Usage (from the spsays-bff directory, so .env is picked up):
|
||||
//
|
||||
// go run ./cmd/seed-book-intro --book cc
|
||||
// go run ./cmd/seed-book-intro --book bg --dir ../extractor/jsons/books/bg/intro
|
||||
//
|
||||
// It is idempotent: a book's existing intro-segments are removed before insert.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/config"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
type introSegment struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Book string `json:"book"`
|
||||
Volume int `json:"volume"`
|
||||
Chapter int `json:"chapter"`
|
||||
Order int `json:"order"`
|
||||
Paragraphs []string `json:"paragraphs"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
book := flag.String("book", "", "book code (e.g. bg, cc) — required")
|
||||
segType := flag.String("type", "intro-segment",
|
||||
"prose_chapters type to seed (intro-segment | chapter-summary)")
|
||||
dir := flag.String("dir", "", "directory of segment JSON files "+
|
||||
"(default: ../extractor/jsons/books/<book>/<intro|chapter-summaries>)")
|
||||
flag.Parse()
|
||||
|
||||
if *book == "" {
|
||||
fmt.Fprintln(os.Stderr, "--book is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
segDir := *dir
|
||||
if segDir == "" {
|
||||
sub := "intro"
|
||||
if *segType == "chapter-summary" {
|
||||
sub = "chapter-summaries"
|
||||
}
|
||||
segDir = filepath.Join("..", "extractor", "jsons", "books", *book, sub)
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger, _ := zap.NewDevelopment()
|
||||
defer logger.Sync()
|
||||
sugar := logger.Sugar()
|
||||
|
||||
mongoDB, err := repository.NewMongoDB(cfg.MongoURI, cfg.MongoDB, logger)
|
||||
if err != nil {
|
||||
sugar.Fatalf("failed to connect to MongoDB: %v", err)
|
||||
}
|
||||
defer mongoDB.Close()
|
||||
|
||||
entries, err := os.ReadDir(segDir)
|
||||
if err != nil {
|
||||
sugar.Fatalf("failed to read dir %s: %v", segDir, err)
|
||||
}
|
||||
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && filepath.Ext(e.Name()) == ".json" {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
|
||||
var segments []introSegment
|
||||
for _, name := range files {
|
||||
raw, err := os.ReadFile(filepath.Join(segDir, name))
|
||||
if err != nil {
|
||||
sugar.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
var seg introSegment
|
||||
if err := json.Unmarshal(raw, &seg); err != nil {
|
||||
sugar.Fatalf("parse %s: %v", name, err)
|
||||
}
|
||||
if seg.Book != *book || seg.Type != *segType {
|
||||
sugar.Warnf("skipping %s (book=%q type=%q)", name, seg.Book, seg.Type)
|
||||
continue
|
||||
}
|
||||
segments = append(segments, seg)
|
||||
}
|
||||
|
||||
if len(segments) == 0 {
|
||||
sugar.Fatalf("no %s %s docs found in %s", *book, *segType, segDir)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
col := mongoDB.Collection("prose_chapters")
|
||||
|
||||
del, err := col.DeleteMany(ctx, map[string]any{
|
||||
"book": *book,
|
||||
"type": *segType,
|
||||
})
|
||||
if err != nil {
|
||||
sugar.Fatalf("delete existing %s %s docs: %v", *book, *segType, err)
|
||||
}
|
||||
sugar.Infof("removed %d existing %s %s doc(s)", del.DeletedCount, *book, *segType)
|
||||
|
||||
docs := make([]any, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
// chapter is null unless the segment carries one (chapter-summaries do).
|
||||
var chapter any
|
||||
if seg.Chapter > 0 {
|
||||
chapter = seg.Chapter
|
||||
}
|
||||
docs = append(docs, map[string]any{
|
||||
"book": *book,
|
||||
"volume": 0,
|
||||
"chapter": chapter,
|
||||
"title": seg.Title,
|
||||
"type": *segType,
|
||||
"slug": seg.Slug,
|
||||
"order": seg.Order,
|
||||
"paragraphs": seg.Paragraphs,
|
||||
})
|
||||
sugar.Infof(" %s (order %d, %d paragraphs)", seg.Slug, seg.Order, len(seg.Paragraphs))
|
||||
}
|
||||
|
||||
res, err := col.InsertMany(ctx, docs)
|
||||
if err != nil {
|
||||
sugar.Fatalf("insert %s %s docs: %v", *book, *segType, err)
|
||||
}
|
||||
sugar.Infof("inserted %d %s %s doc(s)", len(res.InsertedIDs), *book, *segType)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Command uploadrewards is a one-off admin tool that uploads the sādhana
|
||||
// reward images to Cloudflare R2 and writes the resulting public URLs back
|
||||
// into rewards.json (and the embedded copy the BFF seeds from).
|
||||
//
|
||||
// Usage (from the spsays-bff directory, with R2_* in .env):
|
||||
//
|
||||
// go run ./cmd/uploadrewards
|
||||
// go run ./cmd/uploadrewards -json ../sadhna-rewards/rewards.json
|
||||
//
|
||||
// It reads each reward's `file` from the images directory, uploads it to
|
||||
// `<r2_prefix>/<file>` in the configured bucket, and sets `image_url` to the
|
||||
// public URL. Re-running it is safe (objects are overwritten in place).
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
type rewardEntry struct {
|
||||
Key string `json:"key"`
|
||||
File string `json:"file"`
|
||||
Name string `json:"name"`
|
||||
Meaning string `json:"meaning"`
|
||||
Active bool `json:"active"`
|
||||
ImageURL string `json:"image_url"`
|
||||
}
|
||||
|
||||
type catalog struct {
|
||||
Note string `json:"_note,omitempty"`
|
||||
R2Prefix string `json:"r2_prefix"`
|
||||
Rewards []rewardEntry `json:"rewards"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
jsonPath := flag.String("json", "../sadhna-rewards/rewards.json", "path to rewards.json")
|
||||
embedPath := flag.String("embed", "internal/repository/sadhna_rewards.json", "path to the BFF embed copy to keep in sync")
|
||||
envPath := flag.String("env", ".env", "path to .env with R2_* vars")
|
||||
dir := flag.String("dir", "", "images directory (default: directory of -json)")
|
||||
flag.Parse()
|
||||
|
||||
_ = godotenv.Load(*envPath)
|
||||
|
||||
r2cfg := repository.R2Config{
|
||||
AccountID: os.Getenv("R2_ACCOUNT_ID"),
|
||||
AccessKeyID: os.Getenv("R2_ACCESS_KEY_ID"),
|
||||
SecretAccessKey: os.Getenv("R2_SECRET_ACCESS_KEY"),
|
||||
Bucket: os.Getenv("R2_BUCKET"),
|
||||
PublicURL: os.Getenv("R2_PUBLIC_URL"),
|
||||
}
|
||||
r2, err := repository.NewR2(r2cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("R2 init: %v", err)
|
||||
}
|
||||
if r2 == nil {
|
||||
log.Fatalf("R2 not configured — set R2_* in %s", *envPath)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(*jsonPath)
|
||||
if err != nil {
|
||||
log.Fatalf("read %s: %v", *jsonPath, err)
|
||||
}
|
||||
var cat catalog
|
||||
if err := json.Unmarshal(raw, &cat); err != nil {
|
||||
log.Fatalf("parse %s: %v", *jsonPath, err)
|
||||
}
|
||||
|
||||
imagesDir := *dir
|
||||
if imagesDir == "" {
|
||||
imagesDir = filepath.Dir(*jsonPath)
|
||||
}
|
||||
prefix := strings.Trim(cat.R2Prefix, "/")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
for i := range cat.Rewards {
|
||||
rw := &cat.Rewards[i]
|
||||
if rw.File == "" {
|
||||
log.Printf("skip %q: no file", rw.Key)
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(imagesDir, rw.File))
|
||||
if err != nil {
|
||||
log.Fatalf("read image %s: %v", rw.File, err)
|
||||
}
|
||||
key := rw.File
|
||||
if prefix != "" {
|
||||
key = prefix + "/" + rw.File
|
||||
}
|
||||
url, err := r2.PutObject(ctx, key, contentType(rw.File), body)
|
||||
if err != nil {
|
||||
log.Fatalf("upload %s: %v", rw.File, err)
|
||||
}
|
||||
rw.ImageURL = url
|
||||
fmt.Printf("✓ %-32s → %s\n", rw.Key, url)
|
||||
}
|
||||
|
||||
out, err := marshalPretty(cat)
|
||||
if err != nil {
|
||||
log.Fatalf("encode json: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*jsonPath, out, 0o644); err != nil {
|
||||
log.Fatalf("write %s: %v", *jsonPath, err)
|
||||
}
|
||||
if *embedPath != "" {
|
||||
if err := os.WriteFile(*embedPath, out, 0o644); err != nil {
|
||||
log.Fatalf("write embed copy %s: %v", *embedPath, err)
|
||||
}
|
||||
fmt.Printf("\nSynced embed copy: %s\n", *embedPath)
|
||||
}
|
||||
fmt.Printf("Uploaded %d reward image(s). image_url written to %s\n", len(cat.Rewards), *jsonPath)
|
||||
}
|
||||
|
||||
func contentType(file string) string {
|
||||
switch strings.ToLower(filepath.Ext(file)) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".svg":
|
||||
return "image/svg+xml"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func marshalPretty(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false) // keep diacritics / punctuation literal
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -3,23 +3,27 @@ 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
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
github.com/typesense/typesense-go/v2 v2.0.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.2.1
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect
|
||||
@@ -27,14 +31,8 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 // indirect
|
||||
github.com/aws/smithy-go v1.26.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/uuid v1.5.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -49,7 +47,6 @@ require (
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
|
||||
@@ -27,14 +27,6 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDb
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
|
||||
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
|
||||
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
@@ -51,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=
|
||||
@@ -117,18 +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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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=
|
||||
@@ -136,22 +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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
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=
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
@@ -36,13 +37,19 @@ type Config struct {
|
||||
R2Bucket string
|
||||
R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL
|
||||
|
||||
// SMTP — used for password-reset and email-verification mails.
|
||||
// SMTP — used for password-reset, email-verification, and
|
||||
// /feedback admin-notification mails.
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPFrom string // "Prabhupāda Says <noreply@prabhupadasays.org>"
|
||||
|
||||
// Comma-separated FEEDBACK_ADMIN_EMAILS — recipients for the
|
||||
// /feedback notification fan-out. Empty disables the email send
|
||||
// (the DB row is still written and visible via /stats).
|
||||
FeedbackAdminEmails []string
|
||||
|
||||
// Push Notifications (existing — unchanged).
|
||||
FCMCredentialsFile string
|
||||
APNsKeyFile string
|
||||
@@ -58,6 +65,47 @@ type Config struct {
|
||||
|
||||
// Frontend
|
||||
FrontendURL string // for CORS + reset/verify links in emails
|
||||
|
||||
// AdminURL is the admin panel's origin. When set it's added to the CORS
|
||||
// allow-list (the admin app is a separate origin from FrontendURL). Empty
|
||||
// disables admin-origin CORS (only the main frontend is allowed).
|
||||
AdminURL string
|
||||
|
||||
// RevalidateSecret authenticates BFF→frontend on-demand cache
|
||||
// revalidation pings (POST /api/revalidate). Empty disables the pings.
|
||||
RevalidateSecret string
|
||||
|
||||
// BFF public URL — the externally-reachable origin of this server. Used
|
||||
// to build the OAuth redirect_uri the provider sees (must match the
|
||||
// allow-list configured in the provider's console). In dev this is
|
||||
// usually http://localhost:8080.
|
||||
BFFPublicURL string
|
||||
|
||||
// OAuth providers — empty creds disable the corresponding provider.
|
||||
GoogleOAuthClientID string
|
||||
GoogleOAuthClientSecret string
|
||||
FacebookOAuthClientID string
|
||||
FacebookOAuthClientSecret string
|
||||
|
||||
// Soketi (Pusher-compatible WebSocket server) — used to push realtime
|
||||
// notifications. Empty App credentials disable realtime delivery (the
|
||||
// DB row + email still happen). SoketiHost is the HTTP-API origin the
|
||||
// BFF triggers events against (e.g. "ws.prabhupadasays.org"); the
|
||||
// frontend connects to the same host over WSS.
|
||||
SoketiAppID string
|
||||
SoketiAppKey string
|
||||
SoketiAppSecret string
|
||||
SoketiHost string
|
||||
SoketiPort int
|
||||
SoketiUseTLS bool
|
||||
|
||||
// Web Push (VAPID). Empty keys disable push delivery (other channels
|
||||
// still run). VAPIDSubject is a mailto: or https: contact URL required
|
||||
// by the spec. The public key is also served to the browser to
|
||||
// subscribe.
|
||||
VAPIDPublicKey string
|
||||
VAPIDPrivateKey string
|
||||
VAPIDSubject string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables (with .env fallback).
|
||||
@@ -95,6 +143,8 @@ func Load() (*Config, error) {
|
||||
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
||||
SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"),
|
||||
|
||||
FeedbackAdminEmails: parseList(getEnv("FEEDBACK_ADMIN_EMAILS", "")),
|
||||
|
||||
FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""),
|
||||
APNsKeyFile: getEnv("APNS_KEY_FILE", ""),
|
||||
APNsKeyID: getEnv("APNS_KEY_ID", ""),
|
||||
@@ -107,6 +157,26 @@ func Load() (*Config, error) {
|
||||
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
||||
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||
AdminURL: getEnv("ADMIN_URL", ""),
|
||||
RevalidateSecret: getEnv("REVALIDATE_SECRET", ""),
|
||||
|
||||
BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"),
|
||||
|
||||
GoogleOAuthClientID: getEnv("GOOGLE_OAUTH_CLIENT_ID", ""),
|
||||
GoogleOAuthClientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET", ""),
|
||||
FacebookOAuthClientID: getEnv("FACEBOOK_OAUTH_CLIENT_ID", ""),
|
||||
FacebookOAuthClientSecret: getEnv("FACEBOOK_OAUTH_CLIENT_SECRET", ""),
|
||||
|
||||
SoketiAppID: getEnv("SOKETI_APP_ID", ""),
|
||||
SoketiAppKey: getEnv("SOKETI_APP_KEY", ""),
|
||||
SoketiAppSecret: getEnv("SOKETI_APP_SECRET", ""),
|
||||
SoketiHost: getEnv("SOKETI_HOST", "ws.prabhupadasays.org"),
|
||||
SoketiPort: getEnvInt("SOKETI_PORT", 443),
|
||||
SoketiUseTLS: getEnvBool("SOKETI_USE_TLS", true),
|
||||
|
||||
VAPIDPublicKey: getEnv("VAPID_PUBLIC_KEY", ""),
|
||||
VAPIDPrivateKey: getEnv("VAPID_PRIVATE_KEY", ""),
|
||||
VAPIDSubject: getEnv("VAPID_SUBJECT", "mailto:admin@prabhupadasays.org"),
|
||||
}
|
||||
|
||||
if cfg.MongoURI == "" {
|
||||
@@ -147,3 +217,20 @@ func getEnvBool(key string, fallback bool) bool {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// parseList splits a comma-separated env value, trimming whitespace
|
||||
// and discarding empty entries. Returns nil for empty input so
|
||||
// callers can range over it safely without a length check.
|
||||
func parseList(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in
|
||||
// the router, so every method here can assume an admin caller.
|
||||
type AdminHandler struct{ svc *service.AdminService }
|
||||
|
||||
func NewAdminHandler(svc *service.AdminService) *AdminHandler { return &AdminHandler{svc: svc} }
|
||||
|
||||
// pageParams pulls (q, limit, offset) from the query string with sane defaults.
|
||||
func pageParams(r *http.Request) (q string, limit, offset int) {
|
||||
q = strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
limit = 25
|
||||
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 {
|
||||
limit = v
|
||||
}
|
||||
offset = 0
|
||||
if v, err := strconv.Atoi(r.URL.Query().Get("offset")); err == nil && v >= 0 {
|
||||
offset = v
|
||||
}
|
||||
return q, limit, offset
|
||||
}
|
||||
|
||||
// meta builds the pagination envelope metadata from a total + the request page.
|
||||
func meta(total, limit, offset int) *response.Meta {
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
pages := (total + limit - 1) / limit
|
||||
return &response.Meta{
|
||||
Page: offset/limit + 1,
|
||||
Limit: limit,
|
||||
Total: int64(total),
|
||||
TotalPages: pages,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
q, limit, offset := pageParams(r)
|
||||
items, total, err := h.svc.ListUsers(r.Context(), q, limit, offset)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not list users")
|
||||
return
|
||||
}
|
||||
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListPosts(w http.ResponseWriter, r *http.Request) {
|
||||
q, limit, offset := pageParams(r)
|
||||
items, total, err := h.svc.ListPosts(r.Context(), q, limit, offset)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not list posts")
|
||||
return
|
||||
}
|
||||
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListCollections(w http.ResponseWriter, r *http.Request) {
|
||||
q, limit, offset := pageParams(r)
|
||||
items, total, err := h.svc.ListCollections(r.Context(), q, limit, offset)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not list collections")
|
||||
return
|
||||
}
|
||||
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
_, limit, offset := pageParams(r)
|
||||
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||
items, total, err := h.svc.ListFeedback(r.Context(), status, limit, offset)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not list feedback")
|
||||
return
|
||||
}
|
||||
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListReports(w http.ResponseWriter, r *http.Request) {
|
||||
q, limit, offset := pageParams(r)
|
||||
reportType := strings.TrimSpace(r.URL.Query().Get("type"))
|
||||
if reportType == "" {
|
||||
reportType = "post"
|
||||
}
|
||||
if reportType != "post" && reportType != "comment" && reportType != "user" {
|
||||
response.Error(w, http.StatusBadRequest, "type must be post, comment, or user")
|
||||
return
|
||||
}
|
||||
items, total, err := h.svc.ListReports(r.Context(), reportType, q, limit, offset)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not list reports")
|
||||
return
|
||||
}
|
||||
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
|
||||
}
|
||||
|
||||
// NotifyUser — POST /admin/users/{id}/notify { "title": "...", "body": "..." }
|
||||
func (h *AdminHandler) NotifyUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var in struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
in.Title = strings.TrimSpace(in.Title)
|
||||
in.Body = strings.TrimSpace(in.Body)
|
||||
if in.Body == "" {
|
||||
response.Error(w, http.StatusBadRequest, "message body is required")
|
||||
return
|
||||
}
|
||||
if len(in.Title) > 120 || len(in.Body) > 4000 {
|
||||
response.Error(w, http.StatusBadRequest, "message too long")
|
||||
return
|
||||
}
|
||||
err := h.svc.NotifyUser(r.Context(), id, in.Title, in.Body)
|
||||
if errors.Is(err, service.ErrAdminUserNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not send notification")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"sent": true})
|
||||
}
|
||||
|
||||
// ReplyFeedback — POST /admin/feedback/{id}/reply { "message": "..." }
|
||||
func (h *AdminHandler) ReplyFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var in struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
in.Message = strings.TrimSpace(in.Message)
|
||||
if in.Message == "" {
|
||||
response.Error(w, http.StatusBadRequest, "reply message is required")
|
||||
return
|
||||
}
|
||||
if len(in.Message) > 4000 {
|
||||
response.Error(w, http.StatusBadRequest, "reply too long")
|
||||
return
|
||||
}
|
||||
found, emailed, err := h.svc.ReplyFeedback(r.Context(), id, in.Message)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not save reply")
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
response.Error(w, http.StatusNotFound, "feedback not found")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"replied": true, "emailed": emailed})
|
||||
}
|
||||
|
||||
// ResolveReport — POST /admin/reports/{type}/{id}/resolve
|
||||
func (h *AdminHandler) ResolveReport(w http.ResponseWriter, r *http.Request) {
|
||||
reportType := chi.URLParam(r, "type")
|
||||
id := chi.URLParam(r, "id")
|
||||
if reportType != "post" && reportType != "comment" && reportType != "user" {
|
||||
response.Error(w, http.StatusBadRequest, "type must be post, comment, or user")
|
||||
return
|
||||
}
|
||||
resolved, err := h.svc.ResolveReport(r.Context(), reportType, id)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not resolve report")
|
||||
return
|
||||
}
|
||||
// resolved=false means it was already resolved or unknown — still 200 so
|
||||
// the action is idempotent from the client's perspective.
|
||||
response.JSON(w, http.StatusOK, map[string]any{"resolved": resolved})
|
||||
}
|
||||
|
||||
// ─── Moderation actions ──────────────────────────────────────────────────────
|
||||
|
||||
// HidePost — POST /admin/posts/{id}/hide
|
||||
func (h *AdminHandler) HidePost(w http.ResponseWriter, r *http.Request) {
|
||||
h.setPostHidden(w, r, true)
|
||||
}
|
||||
|
||||
// UnhidePost — DELETE /admin/posts/{id}/hide
|
||||
func (h *AdminHandler) UnhidePost(w http.ResponseWriter, r *http.Request) {
|
||||
h.setPostHidden(w, r, false)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) setPostHidden(w http.ResponseWriter, r *http.Request, hidden bool) {
|
||||
ok, err := h.svc.SetPostHidden(r.Context(), chi.URLParam(r, "id"), hidden)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not update post")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "post not found")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"hidden": hidden})
|
||||
}
|
||||
|
||||
// DeletePost — DELETE /admin/posts/{id} (soft-delete; restorable)
|
||||
func (h *AdminHandler) DeletePost(w http.ResponseWriter, r *http.Request) {
|
||||
ok, err := h.svc.DeletePost(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not delete post")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "post not found")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||
}
|
||||
|
||||
// RestorePost — POST /admin/posts/{id}/restore
|
||||
func (h *AdminHandler) RestorePost(w http.ResponseWriter, r *http.Request) {
|
||||
ok, err := h.svc.RestorePost(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not restore post")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "post not found")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"restored": true})
|
||||
}
|
||||
|
||||
// DeleteComment — DELETE /admin/comments/{id} (soft-delete; restorable)
|
||||
func (h *AdminHandler) DeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
ok, err := h.svc.DeleteComment(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not delete comment")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "comment not found")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||
}
|
||||
|
||||
// RestoreComment — POST /admin/comments/{id}/restore
|
||||
func (h *AdminHandler) RestoreComment(w http.ResponseWriter, r *http.Request) {
|
||||
ok, err := h.svc.RestoreComment(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not restore comment")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "comment not found")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"restored": true})
|
||||
}
|
||||
|
||||
// BanUser — POST /admin/users/{id}/ban
|
||||
func (h *AdminHandler) BanUser(w http.ResponseWriter, r *http.Request) {
|
||||
h.setUserBanned(w, r, true)
|
||||
}
|
||||
|
||||
// UnbanUser — DELETE /admin/users/{id}/ban
|
||||
func (h *AdminHandler) UnbanUser(w http.ResponseWriter, r *http.Request) {
|
||||
h.setUserBanned(w, r, false)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) {
|
||||
ok, err := h.svc.SetUserBanned(r.Context(), chi.URLParam(r, "id"), banned)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not update account")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"banned": banned})
|
||||
}
|
||||
+163
-1
@@ -1,13 +1,16 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/geoip"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/validator"
|
||||
)
|
||||
@@ -53,6 +56,21 @@ type verifyReq struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type verifyLoginOTPReq struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
OTP string `json:"otp"`
|
||||
}
|
||||
|
||||
type setEmailReq struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type changePasswordReq struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
// SignUp creates a user, plants the session cookie, returns the user.
|
||||
@@ -79,11 +97,45 @@ func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderAuthErr(w, err)
|
||||
return
|
||||
}
|
||||
h.captureCountry(r, res.User.ID)
|
||||
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||
response.JSON(w, http.StatusCreated, res.User)
|
||||
}
|
||||
|
||||
// LogIn authenticates an existing user.
|
||||
// captureCountry records the new account's country off the critical path.
|
||||
// Cloudflare's CF-IPCountry header is preferred (no network); when absent
|
||||
// — as in the current DNS-only setup — we fall back to a GeoIP lookup on
|
||||
// the client IP. The header read + IP extraction happen synchronously
|
||||
// (they need the live request), then the network lookup + DB write run in
|
||||
// a goroutine so signup latency is untouched. Any failure is silent: the
|
||||
// account simply keeps country = NULL until the user sets it themselves.
|
||||
func (h *AuthHandler) captureCountry(r *http.Request, userID string) {
|
||||
cfCountry := geoip.CountryFromCloudflare(r)
|
||||
ip := geoip.ClientIP(r)
|
||||
go func() {
|
||||
cc := cfCountry
|
||||
if cc == "" {
|
||||
lookupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
cc = geoip.LookupCountryByIP(lookupCtx, ip)
|
||||
cancel()
|
||||
}
|
||||
if cc == "" {
|
||||
return
|
||||
}
|
||||
writeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
h.svc.CaptureCountry(writeCtx, userID, cc)
|
||||
}()
|
||||
}
|
||||
|
||||
// LogIn authenticates an existing user. The response is one of:
|
||||
//
|
||||
// - 200 OK with the User JSON — credentials verified, session cookie set,
|
||||
// frontend treats this as fully signed in.
|
||||
// - 202 Accepted with `{ "otp_required": true }` — credentials verified
|
||||
// but the account has been unverified for over 30 days. An OTP has been
|
||||
// emailed; frontend must collect it and call VerifyLoginOTP next. No
|
||||
// session cookie is set on this branch.
|
||||
func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
|
||||
var req logInReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -103,6 +155,40 @@ func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderAuthErr(w, err)
|
||||
return
|
||||
}
|
||||
if res.OTPRequired {
|
||||
response.JSON(w, http.StatusAccepted, map[string]any{
|
||||
"otp_required": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
auth.Set(w, res.Auth.Token, res.Auth.Expires, h.cookies)
|
||||
response.JSON(w, http.StatusOK, res.Auth.User)
|
||||
}
|
||||
|
||||
// VerifyLoginOTP completes a login that was gated by the stale-unverified
|
||||
// OTP path. Re-checks the password (the OTP alone isn't enough — an
|
||||
// attacker who steals the OTP email still needs the password), validates
|
||||
// the OTP, flips email_verified_at to now(), and sets the session cookie.
|
||||
func (h *AuthHandler) VerifyLoginOTP(w http.ResponseWriter, r *http.Request) {
|
||||
var req verifyLoginOTPReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
v := validator.New()
|
||||
validator.Required(v, "email", req.Email)
|
||||
validator.Required(v, "password", req.Password)
|
||||
validator.Required(v, "otp", req.OTP)
|
||||
if !v.Valid() {
|
||||
response.Error(w, http.StatusBadRequest, v.Error())
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.VerifyLoginOTP(r.Context(), req.Email, req.Password, req.OTP)
|
||||
if err != nil {
|
||||
h.renderAuthErr(w, err)
|
||||
return
|
||||
}
|
||||
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||
response.JSON(w, http.StatusOK, res.User)
|
||||
}
|
||||
@@ -172,6 +258,39 @@ func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// ChangePassword — protected — replaces the caller's password after
|
||||
// verifying the current one. The new password bumps token_version, which
|
||||
// invalidates every previously-issued JWT for this user. We immediately
|
||||
// re-issue a fresh session cookie so the caller's current device stays
|
||||
// logged in; every other device gets signed out on next request.
|
||||
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
|
||||
var req changePasswordReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
// current_password is optional — accounts with no password on file
|
||||
// (OAuth-only signups setting their first one) skip it. The service
|
||||
// enforces verification when a hash exists.
|
||||
v := validator.New()
|
||||
validator.Required(v, "new_password", req.NewPassword)
|
||||
validator.MinLength(v, "new_password", req.NewPassword, 8)
|
||||
if !v.Valid() {
|
||||
response.Error(w, http.StatusBadRequest, v.Error())
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.ChangePassword(r.Context(), uid, req.CurrentPassword, req.NewPassword)
|
||||
if err != nil {
|
||||
h.renderAuthErr(w, err)
|
||||
return
|
||||
}
|
||||
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||
response.JSON(w, http.StatusOK, res.User)
|
||||
}
|
||||
|
||||
// VerifyEmail flips email_verified_at after the user clicks the link.
|
||||
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var req verifyReq
|
||||
@@ -190,6 +309,47 @@ func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// SetEmail — protected — sets (or replaces) the caller's email address.
|
||||
// Used by OAuth-signup users who landed without one, and by anyone wanting
|
||||
// to change their address. The new value is stored as unverified and a
|
||||
// verification link is mailed out immediately.
|
||||
func (h *AuthHandler) SetEmail(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
|
||||
var req setEmailReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
v := validator.New()
|
||||
validator.Required(v, "email", req.Email)
|
||||
validator.Email(v, "email", req.Email)
|
||||
if !v.Valid() {
|
||||
response.Error(w, http.StatusBadRequest, v.Error())
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.svc.SetEmail(r.Context(), uid, req.Email)
|
||||
if err != nil {
|
||||
h.renderAuthErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
// LinkedProviders — protected — returns the OAuth provider names already
|
||||
// linked to the caller's account. Settings UI uses this to flip each
|
||||
// social-connect row between "Connect" and "Connected".
|
||||
func (h *AuthHandler) LinkedProviders(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
providers, err := h.svc.LinkedProviders(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load linked providers")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"providers": providers})
|
||||
}
|
||||
|
||||
// ResendVerification — protected — refires the verification email for the
|
||||
// currently-logged-in user.
|
||||
func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -215,6 +375,8 @@ func (h *AuthHandler) renderAuthErr(w http.ResponseWriter, err error) {
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, service.ErrUserNotFound):
|
||||
response.Error(w, http.StatusNotFound, err.Error())
|
||||
case errors.Is(err, service.ErrAccountSuspended):
|
||||
response.Error(w, http.StatusForbidden, err.Error())
|
||||
default:
|
||||
response.Error(w, http.StatusInternalServerError, "something went wrong")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// CommentHandler serves the post-comment endpoints: listing top-level comments
|
||||
// + a thread's replies (public), and create / like / pin / delete / report
|
||||
// (auth-required).
|
||||
type CommentHandler struct {
|
||||
svc *service.CommentService
|
||||
}
|
||||
|
||||
func NewCommentHandler(svc *service.CommentService) *CommentHandler {
|
||||
return &CommentHandler{svc: svc}
|
||||
}
|
||||
|
||||
type createCommentReq struct {
|
||||
Body string `json:"body"`
|
||||
// ParentID nil → top-level comment; otherwise the comment being replied
|
||||
// to (the service flattens replies onto the thread root).
|
||||
ParentID *string `json:"parent_id"`
|
||||
}
|
||||
|
||||
type commentLikeResp struct {
|
||||
LikesCount int `json:"likes_count"`
|
||||
}
|
||||
|
||||
// List — GET /posts/{id}/comments?limit=&offset=. Top-level comments, pinned
|
||||
// first then newest. Public (OptionalAuth populates viewer_has_liked).
|
||||
func (h *CommentHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
postID := chi.URLParam(r, "id")
|
||||
viewerID := middleware.UserID(r.Context())
|
||||
limit, offset := parseLimitOffset(r)
|
||||
items, total, err := h.svc.List(r.Context(), postID, viewerID, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSONWithMeta(w, http.StatusOK, items, paginationMeta(total, limit, offset))
|
||||
}
|
||||
|
||||
// Replies — GET /comments/{id}/replies?limit=&offset=. A thread's replies,
|
||||
// oldest first. Public (OptionalAuth).
|
||||
func (h *CommentHandler) Replies(w http.ResponseWriter, r *http.Request) {
|
||||
commentID := chi.URLParam(r, "id")
|
||||
viewerID := middleware.UserID(r.Context())
|
||||
limit, offset := parseLimitOffset(r)
|
||||
items, total, err := h.svc.Replies(r.Context(), commentID, viewerID, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSONWithMeta(w, http.StatusOK, items, paginationMeta(total, limit, offset))
|
||||
}
|
||||
|
||||
// Create — POST /posts/{id}/comments. Body: {body, parent_id?}. RequireAuth.
|
||||
func (h *CommentHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
postID := chi.URLParam(r, "id")
|
||||
var req createCommentReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
// Treat an empty parent_id as a top-level comment, not a (missing) reply.
|
||||
if req.ParentID != nil && *req.ParentID == "" {
|
||||
req.ParentID = nil
|
||||
}
|
||||
c, err := h.svc.Add(r.Context(), uid, postID, req.ParentID, req.Body)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, c)
|
||||
}
|
||||
|
||||
// Like — POST /comments/{id}/like. Idempotent. RequireAuth.
|
||||
func (h *CommentHandler) Like(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
count, err := h.svc.Like(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, commentLikeResp{LikesCount: count})
|
||||
}
|
||||
|
||||
// Unlike — DELETE /comments/{id}/like. Idempotent. RequireAuth.
|
||||
func (h *CommentHandler) Unlike(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
count, err := h.svc.Unlike(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, commentLikeResp{LikesCount: count})
|
||||
}
|
||||
|
||||
// Pin — POST /comments/{id}/pin. Post author only; top-level only. RequireAuth.
|
||||
func (h *CommentHandler) Pin(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.svc.Pin(r.Context(), uid, id); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Unpin — DELETE /comments/{id}/pin. Post author only. RequireAuth.
|
||||
func (h *CommentHandler) Unpin(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.svc.Unpin(r.Context(), uid, id); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Delete — DELETE /comments/{id}. Comment author or post author. RequireAuth.
|
||||
// For a thread root this removes the whole thread.
|
||||
func (h *CommentHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.svc.Delete(r.Context(), uid, id); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Report — POST /comments/{id}/report. Body: {category, reason?}. RequireAuth.
|
||||
func (h *CommentHandler) Report(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
var req reportPostReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Report(r.Context(), uid, id, req.Category, req.Reason); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/validator"
|
||||
)
|
||||
|
||||
// FeedbackHandler exposes POST /api/v1/feedback. The router wraps it
|
||||
// with OptionalAuth so a session cookie populates user_id when one is
|
||||
// present, and anonymous callers are still accepted (they have to
|
||||
// supply contact_email instead).
|
||||
type FeedbackHandler struct{ svc *service.FeedbackService }
|
||||
|
||||
func NewFeedbackHandler(svc *service.FeedbackService) *FeedbackHandler {
|
||||
return &FeedbackHandler{svc: svc}
|
||||
}
|
||||
|
||||
type submitFeedbackReq struct {
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
ContactEmail *string `json:"contact_email,omitempty"`
|
||||
}
|
||||
|
||||
func (h *FeedbackHandler) Submit(w http.ResponseWriter, r *http.Request) {
|
||||
var req submitFeedbackReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
v := validator.New()
|
||||
validator.Required(v, "category", req.Category)
|
||||
validator.Required(v, "message", req.Message)
|
||||
validator.MaxLength(v, "message", req.Message, 5000)
|
||||
if !v.Valid() {
|
||||
response.Error(w, http.StatusBadRequest, v.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// OptionalAuth seeds user_id when a valid session cookie was
|
||||
// present; "" means anonymous.
|
||||
uid := middleware.UserID(r.Context())
|
||||
var userIDPtr *string
|
||||
if uid != "" {
|
||||
userIDPtr = &uid
|
||||
}
|
||||
|
||||
// Soft-validate the contact email for the anonymous path. Authed
|
||||
// callers' contact_email is ignored downstream, so skip the check.
|
||||
if userIDPtr == nil && req.ContactEmail != nil && *req.ContactEmail != "" {
|
||||
ev := validator.New()
|
||||
validator.Email(ev, "contact_email", *req.ContactEmail)
|
||||
if !ev.Valid() {
|
||||
response.Error(w, http.StatusBadRequest, ev.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
f, err := h.svc.Submit(r.Context(), userIDPtr, req.Category, req.Message, req.ContactEmail)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, f)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// NotificationHandler serves the signed-in user's notification feed +
|
||||
// unread badge count, and marks them read. All routes sit behind
|
||||
// RequireAuth, so middleware.UserID is always populated.
|
||||
type NotificationHandler struct{ svc *service.NotificationService }
|
||||
|
||||
func NewNotificationHandler(svc *service.NotificationService) *NotificationHandler {
|
||||
return &NotificationHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List returns the caller's notifications, newest first.
|
||||
func (h *NotificationHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
limit, offset := parseLimitOffset(r)
|
||||
items, err := h.svc.List(r.Context(), uid, limit, offset)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load notifications")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// UnreadCount returns {"count": N} for the bell badge.
|
||||
func (h *NotificationHandler) UnreadCount(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
n, err := h.svc.UnreadCount(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load unread count")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]int{"count": n})
|
||||
}
|
||||
|
||||
// MarkAllRead marks every notification read (called when the page opens).
|
||||
func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
if err := h.svc.MarkAllRead(r.Context(), uid); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not mark notifications read")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// GetPrefs — GET /notifications/prefs. The resolved Settings matrix:
|
||||
// {"prefs": {"follows": {"in_app": true, "email": true, "push": true}, …}}.
|
||||
func (h *NotificationHandler) GetPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
matrix, err := h.svc.PrefMatrix(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load notification preferences")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"prefs": matrix})
|
||||
}
|
||||
|
||||
// UpdatePrefs — PUT /notifications/prefs { event, channel, enabled }. Writes
|
||||
// one matrix cell; returns the full re-resolved matrix so the client can
|
||||
// render authoritative state.
|
||||
func (h *NotificationHandler) UpdatePrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
var req struct {
|
||||
Event string `json:"event"`
|
||||
Channel string `json:"channel"`
|
||||
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.SetPref(r.Context(), uid, req.Event, req.Channel, req.Enabled); err != nil {
|
||||
if errors.Is(err, service.ErrUnknownPref) {
|
||||
response.Error(w, http.StatusBadRequest, "unknown event or channel")
|
||||
return
|
||||
}
|
||||
response.Error(w, http.StatusInternalServerError, "could not update notification preferences")
|
||||
return
|
||||
}
|
||||
matrix, err := h.svc.PrefMatrix(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load notification preferences")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"prefs": matrix})
|
||||
}
|
||||
|
||||
type emailPrefsResp struct {
|
||||
LikeEmails bool `json:"like_emails"`
|
||||
}
|
||||
|
||||
// GetEmailPrefs — GET /notifications/email-prefs. The caller's email-notification
|
||||
// preferences (currently just aggregated like emails).
|
||||
func (h *NotificationHandler) GetEmailPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
enabled, err := h.svc.LikeEmailPref(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load email preferences")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, emailPrefsResp{LikeEmails: enabled})
|
||||
}
|
||||
|
||||
// UpdateEmailPrefs — PUT /notifications/email-prefs { like_emails }.
|
||||
func (h *NotificationHandler) UpdateEmailPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
var req struct {
|
||||
LikeEmails bool `json:"like_emails"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if err := h.svc.SetLikeEmailPref(r.Context(), uid, req.LikeEmails); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not update email preferences")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, emailPrefsResp{LikeEmails: req.LikeEmails})
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// stateCookieName is the short-lived cookie that pairs with the state query
|
||||
// param so callback can verify "this redirect was started by us." Scoped to
|
||||
// /api/v1/auth/oauth so it never leaks into other handlers.
|
||||
const stateCookieName = "spsays_oauth_state"
|
||||
const stateCookieMaxAge = 10 * 60 // 10 minutes — matches stateMaxAge in pkg/oauth/state.go
|
||||
|
||||
// OAuthHandler exposes the GET /api/v1/auth/oauth/{provider}/start and
|
||||
// /callback endpoints. It composes Provider → token exchange → service.SignInWithOAuth.
|
||||
type OAuthHandler struct {
|
||||
svc *service.AuthService
|
||||
cookies auth.CookieOptions
|
||||
providers oauth.Registry
|
||||
stateSecret []byte // HMAC key for signing state — derived from JWT secret
|
||||
bffPublicURL string // e.g. http://localhost:8080 or https://api.prabhupadasays.org
|
||||
frontendURL string // e.g. http://localhost:3000 or https://prabhupadasays.org
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewOAuthHandler(
|
||||
svc *service.AuthService,
|
||||
cookies auth.CookieOptions,
|
||||
providers oauth.Registry,
|
||||
stateSecret []byte,
|
||||
bffPublicURL, frontendURL string,
|
||||
logger *zap.Logger,
|
||||
) *OAuthHandler {
|
||||
return &OAuthHandler{
|
||||
svc: svc,
|
||||
cookies: cookies,
|
||||
providers: providers,
|
||||
stateSecret: stateSecret,
|
||||
bffPublicURL: strings.TrimRight(bffPublicURL, "/"),
|
||||
frontendURL: strings.TrimRight(frontendURL, "/"),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Start kicks off the OAuth round-trip. It generates a signed state value,
|
||||
// stores it in a short-lived cookie, and 302s the browser to the provider's
|
||||
// AuthCodeURL. `next` query param carries the post-login redirect path on
|
||||
// the frontend (defaults to "/u/me").
|
||||
func (h *OAuthHandler) Start(w http.ResponseWriter, r *http.Request) {
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
prov, err := h.providers.Get(providerName)
|
||||
if err != nil {
|
||||
http.Error(w, "unknown provider", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
next := sanitizeNext(r.URL.Query().Get("next"))
|
||||
|
||||
state, err := oauth.EncodeState(h.stateSecret, providerName, next, "")
|
||||
if err != nil {
|
||||
h.logger.Error("oauth encode state", zap.Error(err))
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// State cookie matches the auth cookie's SameSite/Secure profile so the
|
||||
// browser sends it back on the callback redirect. Path-scoped so it
|
||||
// doesn't ride along on unrelated requests.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: state,
|
||||
Path: "/api/v1/auth/oauth",
|
||||
Domain: h.cookies.Domain,
|
||||
MaxAge: stateCookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: h.cookies.Secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
cfg := prov.Config(h.callbackURL(providerName))
|
||||
authURL := cfg.AuthCodeURL(state, prov.AuthCodeOptions()...)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// Callback finishes the round-trip. Verifies state, exchanges the code,
|
||||
// fetches the profile, signs the user in, sets the session cookie, and
|
||||
// redirects back to the frontend `next` path.
|
||||
func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
prov, err := h.providers.Get(providerName)
|
||||
if err != nil {
|
||||
h.redirectErr(w, r, "/login?oauth_error=unknown_provider")
|
||||
return
|
||||
}
|
||||
|
||||
// Provider returned an error (user denied, etc.).
|
||||
if e := r.URL.Query().Get("error"); e != "" {
|
||||
h.redirectErr(w, r, "/login?oauth_error="+url.QueryEscape(e))
|
||||
return
|
||||
}
|
||||
|
||||
// State must be present in BOTH cookie and query param, and they must
|
||||
// match. Either alone is not sufficient (cookie alone → no CSRF binding;
|
||||
// query alone → an attacker could craft any signed state).
|
||||
cookie, err := r.Cookie(stateCookieName)
|
||||
if err != nil {
|
||||
h.redirectErr(w, r, "/login?oauth_error=missing_state")
|
||||
return
|
||||
}
|
||||
stateQ := r.URL.Query().Get("state")
|
||||
if stateQ == "" || stateQ != cookie.Value {
|
||||
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
|
||||
return
|
||||
}
|
||||
// Clear the state cookie regardless of what happens next.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: "",
|
||||
Path: "/api/v1/auth/oauth",
|
||||
Domain: h.cookies.Domain,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
HttpOnly: true,
|
||||
Secure: h.cookies.Secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
stateProvider, next, linkUserID, err := oauth.DecodeState(h.stateSecret, stateQ)
|
||||
if err != nil || stateProvider != providerName {
|
||||
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
h.redirectErr(w, r, "/login?oauth_error=missing_code")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := prov.Config(h.callbackURL(providerName))
|
||||
tok, err := cfg.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
h.logger.Warn("oauth code exchange failed", zap.String("provider", providerName), zap.Error(err))
|
||||
h.redirectErr(w, r, "/login?oauth_error=exchange_failed")
|
||||
return
|
||||
}
|
||||
|
||||
prof, err := prov.FetchProfile(r.Context(), tok)
|
||||
if err != nil {
|
||||
h.logger.Warn("oauth fetch profile failed", zap.String("provider", providerName), zap.Error(err))
|
||||
h.redirectErr(w, r, "/login?oauth_error=profile_failed")
|
||||
return
|
||||
}
|
||||
|
||||
// State pinned to a user id means the OAuth round-trip was started from
|
||||
// /link/start (settings page). Dispatch to the link path so we link the
|
||||
// provider to that user instead of running a fresh sign-in.
|
||||
if linkUserID != "" {
|
||||
h.finishLink(w, r, providerName, next, linkUserID, prof)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.SignInWithOAuth(r.Context(), service.OAuthProfile{
|
||||
Provider: prof.Provider,
|
||||
ProviderUserID: prof.ProviderUserID,
|
||||
Email: prof.Email,
|
||||
EmailVerified: prof.EmailVerified,
|
||||
Name: prof.Name,
|
||||
AvatarURL: prof.AvatarURL,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrOAuthUnavailable):
|
||||
h.redirectErr(w, r, "/login?oauth_error=unavailable")
|
||||
default:
|
||||
h.logger.Error("oauth sign-in", zap.Error(err))
|
||||
h.redirectErr(w, r, "/login?oauth_error=server")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
auth.Set(w, res.Token, res.Expires, h.cookies)
|
||||
http.Redirect(w, r, h.frontendURL+next, http.StatusFound)
|
||||
}
|
||||
|
||||
// LinkStart kicks off the OAuth round-trip for an already-authenticated
|
||||
// user who wants to add a provider to their account (settings → Connect).
|
||||
// The caller's user id is signed into the state so the callback knows
|
||||
// which account to link to instead of trying to find one by email.
|
||||
func (h *OAuthHandler) LinkStart(w http.ResponseWriter, r *http.Request) {
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
prov, err := h.providers.Get(providerName)
|
||||
if err != nil {
|
||||
http.Error(w, "unknown provider", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
uid := middleware.UserID(r.Context())
|
||||
if uid == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next := sanitizeNext(r.URL.Query().Get("next"))
|
||||
|
||||
state, err := oauth.EncodeState(h.stateSecret, providerName, next, uid)
|
||||
if err != nil {
|
||||
h.logger.Error("oauth encode link state", zap.Error(err))
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: state,
|
||||
Path: "/api/v1/auth/oauth",
|
||||
Domain: h.cookies.Domain,
|
||||
MaxAge: stateCookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: h.cookies.Secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
cfg := prov.Config(h.callbackURL(providerName))
|
||||
authURL := cfg.AuthCodeURL(state, prov.AuthCodeOptions()...)
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// finishLink completes the link flow once the shared Callback has decoded
|
||||
// state and fetched the provider profile. Bounces back to the frontend
|
||||
// `next` path with `?link=ok|claimed|error` so the settings UI can render
|
||||
// a meaningful message.
|
||||
func (h *OAuthHandler) finishLink(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
providerName, next, linkUserID string,
|
||||
prof *oauth.Profile,
|
||||
) {
|
||||
err := h.svc.LinkOAuthToCurrentUser(r.Context(), linkUserID, service.OAuthProfile{
|
||||
Provider: prof.Provider,
|
||||
ProviderUserID: prof.ProviderUserID,
|
||||
Email: prof.Email,
|
||||
EmailVerified: prof.EmailVerified,
|
||||
Name: prof.Name,
|
||||
AvatarURL: prof.AvatarURL,
|
||||
})
|
||||
target := next
|
||||
switch {
|
||||
case err == nil:
|
||||
target = appendQuery(next, "link", providerName+":ok")
|
||||
case errors.Is(err, service.ErrOAuthAccountClaimed):
|
||||
target = appendQuery(next, "link", providerName+":claimed")
|
||||
default:
|
||||
h.logger.Error("oauth link", zap.String("provider", providerName), zap.Error(err))
|
||||
target = appendQuery(next, "link", providerName+":error")
|
||||
}
|
||||
http.Redirect(w, r, h.frontendURL+target, http.StatusFound)
|
||||
}
|
||||
|
||||
// appendQuery adds a ?key=val (or &key=val) to a same-origin path, preserving
|
||||
// any existing query string. Path must start with "/" — caller guarantees.
|
||||
func appendQuery(path, key, value string) string {
|
||||
sep := "?"
|
||||
if strings.Contains(path, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return path + sep + url.QueryEscape(key) + "=" + url.QueryEscape(value)
|
||||
}
|
||||
|
||||
// callbackURL composes the redirect_uri the provider's console must allow-list.
|
||||
func (h *OAuthHandler) callbackURL(provider string) string {
|
||||
return h.bffPublicURL + "/api/v1/auth/oauth/" + provider + "/callback"
|
||||
}
|
||||
|
||||
// redirectErr sends the user back to the frontend with a single error query
|
||||
// param so the login page can render something meaningful. No internal details
|
||||
// leak through this path.
|
||||
func (h *OAuthHandler) redirectErr(w http.ResponseWriter, r *http.Request, path string) {
|
||||
http.Redirect(w, r, h.frontendURL+path, http.StatusFound)
|
||||
}
|
||||
|
||||
// sanitizeNext bounds the post-login redirect to same-origin paths. Anything
|
||||
// containing a scheme, host, or whitespace is dropped to the default. This is
|
||||
// the same anti-open-redirect treatment we apply on the share-shortener.
|
||||
func sanitizeNext(raw string) string {
|
||||
const def = "/u/me"
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
|
||||
return def
|
||||
}
|
||||
if strings.ContainsAny(raw, " \t\r\n") {
|
||||
return def
|
||||
}
|
||||
if len(raw) > 512 {
|
||||
return def
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// Subscription limits. The endpoint length cap + host allow-list guard against
|
||||
// junk/abusive rows and SSRF (the BFF POSTs to the endpoint when sending), and
|
||||
// the per-user device cap bounds fan-out.
|
||||
const (
|
||||
maxEndpointLen = 2048
|
||||
maxKeyLen = 256
|
||||
maxDevicesPerUser = 10
|
||||
|
||||
// RFC 8291 fixes the key sizes: p256dh is an uncompressed P-256 point,
|
||||
// auth a 16-byte secret. Anything else is guaranteed to fail at send time.
|
||||
p256dhLen = 65
|
||||
authLen = 16
|
||||
)
|
||||
|
||||
// validatePushEndpoint requires an https URL belonging to a known browser push
|
||||
// service — preventing the endpoint from pointing at internal/arbitrary hosts
|
||||
// (SSRF) and keeping the table free of junk.
|
||||
func validatePushEndpoint(endpoint string) bool {
|
||||
if l := len(endpoint); l == 0 || l > maxEndpointLen {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(u.Hostname())
|
||||
switch host {
|
||||
case "fcm.googleapis.com", "updates.push.services.mozilla.com":
|
||||
return true // Chrome/Brave/Edge (FCM), Firefox
|
||||
}
|
||||
// Safari/iOS (*.push.apple.com) and Windows/Edge WNS (*.notify.windows.com).
|
||||
for _, suffix := range []string{".push.apple.com", ".notify.windows.com", ".wns.windows.com"} {
|
||||
if strings.HasSuffix(host, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateSubscriptionKey checks that a client key is base64(url)-decodable
|
||||
// to exactly `want` bytes — the same decoding webpush-go applies at send
|
||||
// time, so anything accepted here is actually deliverable.
|
||||
func validateSubscriptionKey(key string, want int) bool {
|
||||
if l := len(key); l == 0 || l > maxKeyLen {
|
||||
return false
|
||||
}
|
||||
trimmed := strings.TrimRight(key, "=")
|
||||
b, err := base64.RawURLEncoding.DecodeString(trimmed)
|
||||
if err != nil {
|
||||
b, err = base64.RawStdEncoding.DecodeString(trimmed)
|
||||
}
|
||||
return err == nil && len(b) == want
|
||||
}
|
||||
|
||||
// PushHandler serves Web Push subscription management. The VAPID public key
|
||||
// is handed to the browser so it can subscribe; subscriptions are stored per
|
||||
// user. All routes require auth.
|
||||
type PushHandler struct {
|
||||
push *service.PushSender // optional — nil when VAPID isn't configured
|
||||
repo *repository.PushRepo
|
||||
}
|
||||
|
||||
func NewPushHandler(push *service.PushSender, repo *repository.PushRepo) *PushHandler {
|
||||
return &PushHandler{push: push, repo: repo}
|
||||
}
|
||||
|
||||
// Key — GET /push/key. Returns the VAPID public key + whether push is on.
|
||||
func (h *PushHandler) Key(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, http.StatusOK, struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}{
|
||||
PublicKey: h.push.PublicKey(),
|
||||
Enabled: h.push.Enabled(),
|
||||
})
|
||||
}
|
||||
|
||||
type subscribeReq struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys struct {
|
||||
P256dh string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
// Subscribe — POST /push/subscribe. Stores (or refreshes) the caller's push
|
||||
// subscription. Body is the browser PushSubscription JSON.
|
||||
func (h *PushHandler) Subscribe(w http.ResponseWriter, r *http.Request) {
|
||||
if h.repo == nil || !h.push.Enabled() {
|
||||
response.Error(w, http.StatusServiceUnavailable, "push not configured")
|
||||
return
|
||||
}
|
||||
uid := middleware.UserID(r.Context())
|
||||
var req subscribeReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" {
|
||||
response.Error(w, http.StatusBadRequest, "missing subscription fields")
|
||||
return
|
||||
}
|
||||
if !validatePushEndpoint(req.Endpoint) {
|
||||
response.Error(w, http.StatusBadRequest, "invalid push endpoint")
|
||||
return
|
||||
}
|
||||
if !validateSubscriptionKey(req.Keys.P256dh, p256dhLen) ||
|
||||
!validateSubscriptionKey(req.Keys.Auth, authLen) {
|
||||
response.Error(w, http.StatusBadRequest, "invalid subscription keys")
|
||||
return
|
||||
}
|
||||
if err := h.repo.Upsert(r.Context(), uid, req.Endpoint, req.Keys.P256dh, req.Keys.Auth, r.UserAgent()); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not save subscription")
|
||||
return
|
||||
}
|
||||
// Keep only the most-recent N devices per user (best-effort).
|
||||
_ = h.repo.PruneToMostRecent(r.Context(), uid, maxDevicesPerUser)
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
type unsubscribeReq struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
// Unsubscribe — POST /push/unsubscribe. Removes one of the caller's own
|
||||
// subscriptions by endpoint (scoped to the user so nobody can delete another
|
||||
// account's subscription by guessing/replaying an endpoint).
|
||||
func (h *PushHandler) Unsubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
if h.repo == nil {
|
||||
response.NoContent(w)
|
||||
return
|
||||
}
|
||||
uid := middleware.UserID(r.Context())
|
||||
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.DeleteByEndpointForUser(r.Context(), uid, req.Endpoint); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not remove subscription")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// RelationshipHandler exposes the follow / block / report actions, all
|
||||
// scoped to a target user identified by {handle} in the path and a
|
||||
// signed-in viewer pulled from the auth middleware. Every mutating
|
||||
// endpoint returns the recomputed RelationshipResult (viewer's state +
|
||||
// the target's live counts) so the frontend updates its buttons and
|
||||
// stat line from the single response — except Report, which returns 204.
|
||||
//
|
||||
// These routes live under /api/v1/u/{handle}/… in a RequireAuth group;
|
||||
// the public profile GET stays anonymous-friendly in its own group.
|
||||
type RelationshipHandler struct{ svc *service.SocialGraphService }
|
||||
|
||||
func NewRelationshipHandler(svc *service.SocialGraphService) *RelationshipHandler {
|
||||
return &RelationshipHandler{svc: svc}
|
||||
}
|
||||
|
||||
// Follow sends a follow REQUEST (pending until the target accepts).
|
||||
func (h *RelationshipHandler) Follow(w http.ResponseWriter, r *http.Request) {
|
||||
h.act(w, r, h.svc.RequestFollow)
|
||||
}
|
||||
|
||||
// Unfollow removes the viewer's edge — stops following or cancels a
|
||||
// still-pending request.
|
||||
func (h *RelationshipHandler) Unfollow(w http.ResponseWriter, r *http.Request) {
|
||||
h.act(w, r, h.svc.Unfollow)
|
||||
}
|
||||
|
||||
// AcceptFollow accepts the target's pending request to follow the viewer.
|
||||
func (h *RelationshipHandler) AcceptFollow(w http.ResponseWriter, r *http.Request) {
|
||||
h.act(w, r, h.svc.AcceptFollow)
|
||||
}
|
||||
|
||||
// RejectFollow declines the target's pending request.
|
||||
func (h *RelationshipHandler) RejectFollow(w http.ResponseWriter, r *http.Request) {
|
||||
h.act(w, r, h.svc.RejectFollow)
|
||||
}
|
||||
|
||||
// RemoveFollower drops the target from the viewer's followers.
|
||||
func (h *RelationshipHandler) RemoveFollower(w http.ResponseWriter, r *http.Request) {
|
||||
h.act(w, r, h.svc.RemoveFollower)
|
||||
}
|
||||
|
||||
// Block blocks the target (and severs follows both ways).
|
||||
func (h *RelationshipHandler) Block(w http.ResponseWriter, r *http.Request) {
|
||||
h.act(w, r, h.svc.Block)
|
||||
}
|
||||
|
||||
// Unblock removes the viewer's block on the target.
|
||||
func (h *RelationshipHandler) Unblock(w http.ResponseWriter, r *http.Request) {
|
||||
h.act(w, r, h.svc.Unblock)
|
||||
}
|
||||
|
||||
type reportReq struct {
|
||||
Category string `json:"category"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Report files a report against the target. Body: {category, reason}.
|
||||
func (h *RelationshipHandler) Report(w http.ResponseWriter, r *http.Request) {
|
||||
viewerID := middleware.UserID(r.Context())
|
||||
handle := chi.URLParam(r, "handle")
|
||||
var req reportReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Report(r.Context(), viewerID, handle, req.Category, req.Reason); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// act is the shared shim for every RelationshipResult-returning action:
|
||||
// pull viewer + handle, run the service method, map errors, return JSON.
|
||||
func (h *RelationshipHandler) act(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
fn func(ctx context.Context, viewerID, handle string) (*service.RelationshipResult, error),
|
||||
) {
|
||||
viewerID := middleware.UserID(r.Context())
|
||||
handle := chi.URLParam(r, "handle")
|
||||
res, err := fn(r.Context(), viewerID, handle)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, res)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// SitemapHandler serves the flat list of public content URLs the frontend
|
||||
// turns into sitemap XML. Public + heavily cached on the frontend (daily
|
||||
// ISR), so this expensive-ish sweep runs ~once/day, never per crawler request.
|
||||
type SitemapHandler struct{ repo *repository.SitemapRepo }
|
||||
|
||||
func NewSitemapHandler(repo *repository.SitemapRepo) *SitemapHandler {
|
||||
return &SitemapHandler{repo: repo}
|
||||
}
|
||||
|
||||
// GetContent — GET /sitemap/content → { "urls": ["/books/bg/1/1", …] }.
|
||||
func (h *SitemapHandler) GetContent(w http.ResponseWriter, r *http.Request) {
|
||||
// The sweep touches tens of thousands of docs across several collections;
|
||||
// give it room but bound it so a stuck query can't hang the handler.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
urls, err := h.repo.ContentURLs(ctx)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not build sitemap")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"urls": urls})
|
||||
}
|
||||
+417
-9
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
@@ -26,15 +27,43 @@ import (
|
||||
// PostHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PostHandler struct{ svc *service.PostService }
|
||||
type PostHandler struct {
|
||||
svc *service.PostService
|
||||
graph *service.SocialGraphService // post reporting
|
||||
}
|
||||
|
||||
func NewPostHandler(svc *service.PostService) *PostHandler { return &PostHandler{svc: svc} }
|
||||
func NewPostHandler(svc *service.PostService, graph *service.SocialGraphService) *PostHandler {
|
||||
return &PostHandler{svc: svc, graph: graph}
|
||||
}
|
||||
|
||||
type reportPostReq struct {
|
||||
Category string `json:"category"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Report files a report against this post. Body: {category, reason?}.
|
||||
// Auth-required; the reporter is the signed-in viewer.
|
||||
func (h *PostHandler) Report(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
var req reportPostReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if err := h.graph.ReportPost(r.Context(), uid, id, req.Category, req.Reason); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
type createPostReq struct {
|
||||
Caption string `json:"caption"`
|
||||
SourceURL string `json:"source_url"`
|
||||
SourceTitle string `json:"source_title"`
|
||||
ImageURLs []string `json:"image_urls"`
|
||||
MentionUserIDs []string `json:"mention_user_ids"`
|
||||
}
|
||||
|
||||
func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -51,7 +80,7 @@ func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusBadRequest, v.Error())
|
||||
return
|
||||
}
|
||||
p, err := h.svc.Create(r.Context(), uid, req.Caption, req.SourceURL, req.SourceTitle, req.ImageURLs)
|
||||
p, err := h.svc.Create(r.Context(), uid, req.Caption, req.SourceURL, req.SourceTitle, req.ImageURLs, req.MentionUserIDs)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
@@ -59,9 +88,91 @@ func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
// MentionSuggest — GET /posts/mention-suggest?q=&limit=&offset=. Prioritised
|
||||
// user suggestions for the composer (following → followers → others).
|
||||
func (h *PostHandler) MentionSuggest(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
limit, offset := parseLimitOffset(r)
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
users, err := h.svc.SuggestMentions(r.Context(), uid, q, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
if users == nil {
|
||||
users = []model.UserSummary{}
|
||||
}
|
||||
response.JSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// ListMentions — GET /posts/{id}/mentions. The users tagged on a post. Public.
|
||||
func (h *PostHandler) ListMentions(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := h.svc.Mentions(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// AddMention — POST /posts/{id}/mentions { user_id }. Author only.
|
||||
func (h *PostHandler) AddMention(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
var req struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.UserID) == "" {
|
||||
response.Error(w, http.StatusBadRequest, "missing user_id")
|
||||
return
|
||||
}
|
||||
if err := h.svc.AddMention(r.Context(), uid, chi.URLParam(r, "id"), req.UserID); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// RemoveMention — DELETE /posts/{id}/mentions/{userID}. Author only.
|
||||
func (h *PostHandler) RemoveMention(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
if err := h.svc.RemoveMention(r.Context(), uid, chi.URLParam(r, "id"), chi.URLParam(r, "userID")); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Reshare materialises a reshare of the post identified by {id} as a
|
||||
// new posts row owned by the caller. Idempotent — second call returns
|
||||
// the same row (still 201). The body is the new (or existing) post so
|
||||
// the frontend can drop it into its local feed without a refetch.
|
||||
func (h *PostHandler) Reshare(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
p, err := h.svc.Reshare(r.Context(), id, uid)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
// Unreshare removes a previously recorded reshare. Idempotent.
|
||||
func (h *PostHandler) Unreshare(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.svc.Unreshare(r.Context(), id, uid); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *PostHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
p, err := h.svc.Get(r.Context(), id)
|
||||
// viewerID is "" for anonymous viewers — the service treats that as
|
||||
// "no reshare hydration" so the public-cache shape is unchanged.
|
||||
p, err := h.svc.Get(r.Context(), id, middleware.UserID(r.Context()))
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
@@ -69,6 +180,51 @@ func (h *PostHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
// likeResp is returned by Like / Unlike — the new viewer state + effective
|
||||
// total (likes_count + pending Redis delta) so the client can reconcile.
|
||||
type likeResp struct {
|
||||
Liked bool `json:"liked"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
}
|
||||
|
||||
// Like — POST /posts/{id}/like. Idempotent. RequireAuth.
|
||||
func (h *PostHandler) Like(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
count, err := h.svc.Like(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, likeResp{Liked: true, LikesCount: count})
|
||||
}
|
||||
|
||||
// Unlike — DELETE /posts/{id}/like. Idempotent. RequireAuth.
|
||||
func (h *PostHandler) Unlike(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
id := chi.URLParam(r, "id")
|
||||
count, err := h.svc.Unlike(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, likeResp{Liked: false, LikesCount: count})
|
||||
}
|
||||
|
||||
// ListLikes — GET /posts/{id}/likes?limit=&offset=&q=. Paginated "liked by"
|
||||
// users with optional name/handle search. Public (OptionalAuth).
|
||||
func (h *PostHandler) ListLikes(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
limit, offset := parseLimitOffset(r)
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
users, total, err := h.svc.Likers(r.Context(), id, q, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
|
||||
}
|
||||
|
||||
func (h *PostHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id"), uid); err != nil {
|
||||
@@ -78,6 +234,27 @@ func (h *PostHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Hide — POST /posts/{id}/hide. Owner hides the post from timelines + public
|
||||
// collections.
|
||||
func (h *PostHandler) Hide(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
if err := h.svc.SetHidden(r.Context(), chi.URLParam(r, "id"), uid, true); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Unhide — DELETE /posts/{id}/hide. Restores the post to timelines.
|
||||
func (h *PostHandler) Unhide(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
if err := h.svc.SetHidden(r.Context(), chi.URLParam(r, "id"), uid, false); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
limit, offset := parseLimitOffset(r)
|
||||
@@ -89,6 +266,57 @@ func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
|
||||
}
|
||||
|
||||
// ListHidden — GET /posts/hidden. The caller's hidden posts (auth required),
|
||||
// for the /u/me "Hidden" tab where they can unhide them.
|
||||
func (h *PostHandler) ListHidden(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
limit, offset := parseLimitOffset(r)
|
||||
posts, total, err := h.svc.ListHidden(r.Context(), uid, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
|
||||
}
|
||||
|
||||
// Feed — GET /feed?limit=&offset=. The global personalised home feed (auth
|
||||
// required). Returns at most `limit` posts; the client infers "more" from a
|
||||
// full page (no total count for an unbounded global feed).
|
||||
func (h *PostHandler) Feed(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
limit, offset := parseLimitOffset(r)
|
||||
posts, err := h.svc.Feed(r.Context(), uid, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, posts)
|
||||
}
|
||||
|
||||
// FeedReset — POST /feed/reset. Clears the caller's home-feed seen-set so the
|
||||
// next page starts fresh. Called on logout and on page unload (the realtime
|
||||
// socket dropping) to evict the set promptly rather than waiting for its TTL.
|
||||
func (h *PostHandler) FeedReset(w http.ResponseWriter, r *http.Request) {
|
||||
h.svc.ResetFeed(r.Context(), middleware.UserID(r.Context()))
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// FeedNewUsers — GET /feed/new-users. Up to 5 accounts that joined in the last
|
||||
// 24h, for the feed's "new on the platform" discovery strip. Returns [] when
|
||||
// there are none (the client hides the section).
|
||||
func (h *PostHandler) FeedNewUsers(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
users, err := h.graph.NewUsers(r.Context(), uid, 5)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
if users == nil {
|
||||
users = []model.UserSummary{}
|
||||
}
|
||||
response.JSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
type replaceImagesReq struct {
|
||||
ImageURLs []string `json:"image_urls"`
|
||||
}
|
||||
@@ -386,10 +614,14 @@ type ProfileHandler struct {
|
||||
users *repository.UserRepo
|
||||
posts *repository.PostRepo
|
||||
collections *repository.CollectionRepo
|
||||
graph *service.SocialGraphService
|
||||
likes *repository.LikeRepo
|
||||
mentions *repository.MentionRepo
|
||||
comments *repository.CommentRepo
|
||||
}
|
||||
|
||||
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo) *ProfileHandler {
|
||||
return &ProfileHandler{users: users, posts: posts, collections: collections}
|
||||
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, graph *service.SocialGraphService, likes *repository.LikeRepo, mentions *repository.MentionRepo, comments *repository.CommentRepo) *ProfileHandler {
|
||||
return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph, likes: likes, mentions: mentions, comments: comments}
|
||||
}
|
||||
|
||||
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -404,34 +636,205 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load profile")
|
||||
return
|
||||
}
|
||||
// Hidden-post count is meaningful only to the owner (drives the /u/me
|
||||
// "Hidden" tab). Compute it just for self; 0 for everyone else.
|
||||
hiddenCount := 0
|
||||
if middleware.UserID(r.Context()) == u.ID {
|
||||
hiddenCount, err = h.posts.CountHiddenByUser(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load profile")
|
||||
return
|
||||
}
|
||||
}
|
||||
cols, err := h.collections.ListPublicByUser(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load collections")
|
||||
return
|
||||
}
|
||||
// Follower/following counts + the viewer's relationship to this
|
||||
// profile. viewerID is "" for anonymous viewers (OptionalAuth) and
|
||||
// equal to u.ID when the owner hits their own /u/{handle} — both
|
||||
// cases yield a nil `relationship` so the frontend hides the
|
||||
// follow/block affordances.
|
||||
viewerID := middleware.UserID(r.Context())
|
||||
followers, following, rel, err := h.graph.ProfileExtras(r.Context(), viewerID, u)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load relationship")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"handle": u.Handle,
|
||||
"name": u.Name,
|
||||
"avatar_url": u.AvatarURL,
|
||||
"joined_at": u.CreatedAt,
|
||||
"country": u.Country,
|
||||
"post_count": count,
|
||||
"hidden_count": hiddenCount,
|
||||
"follower_count": followers,
|
||||
"following_count": following,
|
||||
"relationship": rel,
|
||||
"public_collections": cols,
|
||||
})
|
||||
}
|
||||
|
||||
// gateByBlock resolves the profile owner by handle and returns ErrBlocked
|
||||
// (→403) when a block exists in either direction between the signed-in
|
||||
// viewer and the owner — so a blocked viewer can't read the owner's feed,
|
||||
// tagged posts, or follower lists, even by hitting the API directly. It
|
||||
// returns the resolved owner so callers needn't look it up again, and
|
||||
// propagates ErrUserNotFound (→404) for an unknown handle. Anonymous
|
||||
// viewers and owners viewing their own profile are never gated.
|
||||
func (h *ProfileHandler) gateByBlock(r *http.Request, handle string) (*model.User, error) {
|
||||
owner, err := h.users.FindByHandle(r.Context(), handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
viewerID := middleware.UserID(r.Context())
|
||||
if viewerID == "" || viewerID == owner.ID {
|
||||
return owner, nil
|
||||
}
|
||||
blocked, err := h.graph.IsBlockedBetween(r.Context(), viewerID, owner.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if blocked {
|
||||
return nil, service.ErrBlocked
|
||||
}
|
||||
return owner, nil
|
||||
}
|
||||
|
||||
// Feed returns posts for the public feed. Same payload as ListMine but
|
||||
// looked up by handle so it's reachable anonymously.
|
||||
// looked up by handle so it's reachable anonymously. When an
|
||||
// authenticated viewer hits this route (via OptionalAuth) each post
|
||||
// carries `viewer_has_reshared` so the action footer renders the right
|
||||
// button. Anonymous viewers get `false` for every post at zero cost.
|
||||
func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
|
||||
handle := chi.URLParam(r, "handle")
|
||||
limit, offset := parseLimitOffset(r)
|
||||
posts, total, err := h.posts.ListByHandle(r.Context(), handle, limit, offset)
|
||||
owner, err := h.gateByBlock(r, handle)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
limit, offset := parseLimitOffset(r)
|
||||
// gateByBlock already resolved the owner, so list by id rather than
|
||||
// re-resolving the handle. Hidden posts never appear here (they live in
|
||||
// the owner's separate Hidden tab).
|
||||
posts, total, err := h.posts.ListByUser(r.Context(), owner.ID, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := h.hydrateFeed(r, posts); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
|
||||
}
|
||||
|
||||
// MentionsFeed — GET /u/{handle}/mentions?limit=&offset=. Posts that tag this
|
||||
// user, newest first. Public (OptionalAuth) with viewer-state hydration.
|
||||
func (h *ProfileHandler) MentionsFeed(w http.ResponseWriter, r *http.Request) {
|
||||
handle := chi.URLParam(r, "handle")
|
||||
if _, err := h.gateByBlock(r, handle); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
limit, offset := parseLimitOffset(r)
|
||||
posts, total, err := h.posts.ListMentioningByHandle(r.Context(), handle, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := h.hydrateFeed(r, posts); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
|
||||
}
|
||||
|
||||
// hydrateFeed fills viewer reshare state + like counts/state + mention counts
|
||||
// across a page of posts. Shared by the profile feed and the mentions feed.
|
||||
func (h *ProfileHandler) hydrateFeed(r *http.Request, posts []model.Post) error {
|
||||
if len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
viewerID := middleware.UserID(r.Context())
|
||||
if viewerID != "" {
|
||||
ids := make([]string, 0, len(posts))
|
||||
for i := range posts {
|
||||
if posts[i].UserID != viewerID { // can't reshare your own post
|
||||
ids = append(ids, posts[i].ID)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
states, err := h.posts.ReshareStatesFor(r.Context(), viewerID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range posts {
|
||||
if states[posts[i].ID] {
|
||||
posts[i].ViewerHasReshared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ptrs := make([]*model.Post, len(posts))
|
||||
for i := range posts {
|
||||
ptrs[i] = &posts[i]
|
||||
}
|
||||
if err := h.likes.Hydrate(r.Context(), viewerID, ptrs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.mentions.HydrateCounts(r.Context(), ptrs); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.comments.Hydrate(r.Context(), viewerID, ptrs)
|
||||
}
|
||||
|
||||
// followListResp is the payload for the followers / following list endpoints:
|
||||
// the page of users plus the total count (so the client can paginate). Total
|
||||
// rides in `data` rather than meta because the frontend's fetch wrapper only
|
||||
// surfaces `data`.
|
||||
type followListResp struct {
|
||||
Users []model.UserSummary `json:"users"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// Followers — GET /u/{handle}/followers?limit=&offset=&q=. Paginated, optional
|
||||
// name/handle search. Public (OptionalAuth).
|
||||
func (h *ProfileHandler) Followers(w http.ResponseWriter, r *http.Request) {
|
||||
handle := chi.URLParam(r, "handle")
|
||||
if _, err := h.gateByBlock(r, handle); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
limit, offset := parseLimitOffset(r)
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
users, total, err := h.graph.Followers(r.Context(), handle, q, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
|
||||
}
|
||||
|
||||
// Following — GET /u/{handle}/following?limit=&offset=&q=.
|
||||
func (h *ProfileHandler) Following(w http.ResponseWriter, r *http.Request) {
|
||||
handle := chi.URLParam(r, "handle")
|
||||
if _, err := h.gateByBlock(r, handle); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
limit, offset := parseLimitOffset(r)
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
users, total, err := h.graph.Following(r.Context(), handle, q, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -440,12 +843,17 @@ func mapSocialErr(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, repository.ErrUserNotFound),
|
||||
errors.Is(err, repository.ErrPostNotFound),
|
||||
errors.Is(err, repository.ErrCommentNotFound),
|
||||
errors.Is(err, repository.ErrBookmarkNotFound),
|
||||
errors.Is(err, repository.ErrCollectionNotFound),
|
||||
errors.Is(err, service.ErrNotFound):
|
||||
response.Error(w, http.StatusNotFound, "not found")
|
||||
case errors.Is(err, repository.ErrDuplicateComment):
|
||||
response.Error(w, http.StatusConflict, "you've already posted this comment")
|
||||
case errors.Is(err, service.ErrForbidden):
|
||||
response.Error(w, http.StatusForbidden, "not allowed")
|
||||
case errors.Is(err, service.ErrBlocked):
|
||||
response.Error(w, http.StatusForbidden, "this action isn't available between you and this user")
|
||||
case errors.Is(err, service.ErrPlanLimit):
|
||||
response.Error(w, http.StatusPaymentRequired, err.Error())
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// notifChannelPrefix is the public per-user channel the frontend subscribes to
|
||||
// for realtime notifications. Because it's keyed by user, a `channel_vacated`
|
||||
// webhook on it means that user's *last* connection dropped — i.e. they went
|
||||
// offline (across all their tabs) — so we can evict their feed seen-set.
|
||||
const notifChannelPrefix = "notifications."
|
||||
|
||||
// SoketiWebhookHandler receives Soketi (Pusher-compatible) webhooks. Soketi
|
||||
// signs each delivery with the app secret (X-Pusher-Signature = hex
|
||||
// HMAC-SHA256 of the raw body); we verify before acting. On channel_vacated
|
||||
// for a per-user notifications channel we clear that user's home-feed seen-set
|
||||
// — prompt cleanup of a true offline/disconnect, complementing the page-unload
|
||||
// beacon and the set's own TTL.
|
||||
type SoketiWebhookHandler struct {
|
||||
appKey string
|
||||
appSecret string
|
||||
feed *repository.FeedRepo
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewSoketiWebhookHandler(appKey, appSecret string, feed *repository.FeedRepo, logger *zap.Logger) *SoketiWebhookHandler {
|
||||
return &SoketiWebhookHandler{appKey: appKey, appSecret: appSecret, feed: feed, logger: logger}
|
||||
}
|
||||
|
||||
type soketiWebhookBody struct {
|
||||
Events []struct {
|
||||
Name string `json:"name"`
|
||||
Channel string `json:"channel"`
|
||||
} `json:"events"`
|
||||
}
|
||||
|
||||
// Handle — POST /webhooks/soketi. Public route: the HMAC signature is the
|
||||
// authentication, not a session cookie. Returns 200 on a valid signature so
|
||||
// Soketi doesn't retry; 401 on a bad/absent signature.
|
||||
func (h *SoketiWebhookHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
// Without a configured secret there's nothing to verify against — refuse
|
||||
// rather than trust unsigned input.
|
||||
if h.appSecret == "" {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB cap
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if key := r.Header.Get("X-Pusher-Key"); h.appKey != "" && key != h.appKey {
|
||||
h.logger.Warn("soketi webhook: app key mismatch", zap.String("got", key))
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !validSoketiSignature(h.appSecret, r.Header.Get("X-Pusher-Signature"), body) {
|
||||
// Almost always a secret mismatch between Soketi and the BFF.
|
||||
h.logger.Warn("soketi webhook: bad signature", zap.Int("body_bytes", len(body)))
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var payload soketiWebhookBody
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
h.logger.Warn("soketi webhook: bad payload", zap.Error(err))
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
cleared := 0
|
||||
for _, e := range payload.Events {
|
||||
// A per-user notifications channel emptying = that user is offline.
|
||||
if e.Name != "channel_vacated" {
|
||||
continue
|
||||
}
|
||||
if uid, ok := strings.CutPrefix(e.Channel, notifChannelPrefix); ok && uid != "" {
|
||||
h.feed.Reset(ctx, uid)
|
||||
cleared++
|
||||
}
|
||||
}
|
||||
h.logger.Debug("soketi webhook: verified",
|
||||
zap.Int("events", len(payload.Events)),
|
||||
zap.Int("feed_sets_cleared", cleared))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// validSoketiSignature is a constant-time check of the hex HMAC-SHA256 of the
|
||||
// raw body keyed by the app secret.
|
||||
func validSoketiSignature(secret, provided string, body []byte) bool {
|
||||
if provided == "" {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(expected), []byte(provided))
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
)
|
||||
|
||||
// StatsHandler renders a single-page HTML snapshot of the system —
|
||||
// aggregate counts plus the most-recent feedback rows. Mounted at /stats
|
||||
// (outside /api/v1, since it returns HTML not JSON) and intentionally
|
||||
// public per current product call. If that ever needs to change, wrap
|
||||
// the route in middleware in server.go.
|
||||
//
|
||||
// It also handles the "Mark resolved" form POST per feedback row, which
|
||||
// stamps resolved_at (one-way) and fires the author thank-you email via
|
||||
// the feedback service.
|
||||
type StatsHandler struct {
|
||||
stats *repository.StatsRepo
|
||||
feedback *repository.FeedbackRepo
|
||||
svc *service.FeedbackService
|
||||
}
|
||||
|
||||
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo, svc *service.FeedbackService) *StatsHandler {
|
||||
return &StatsHandler{stats: stats, feedback: feedback, svc: svc}
|
||||
}
|
||||
|
||||
func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
|
||||
snap, err := h.stats.Snapshot(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "stats query failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
recent, err := h.feedback.Recent(r.Context(), 25)
|
||||
if err != nil {
|
||||
http.Error(w, "recent feedback query failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// Caching off — this is an ops snapshot; staleness defeats the point.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err := statsTpl.Execute(w, map[string]any{
|
||||
"Stats": snap,
|
||||
"Feedback": recent,
|
||||
"GeneratedAt": time.Now().Format("2006-01-02 15:04:05 MST"),
|
||||
}); err != nil {
|
||||
// Headers already flushed — best we can do is bail mid-stream.
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// AcknowledgeFeedback handles the per-row "Acknowledge" form POST. Marks
|
||||
// the row acknowledged (one-way) and emails the author that we've seen
|
||||
// it and started work, then 303-redirects back to /stats. Already-
|
||||
// acknowledged or unknown ids redirect silently.
|
||||
func (h *StatsHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.svc.Acknowledge(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) {
|
||||
http.Error(w, "acknowledge failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/stats", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// ResolveFeedback handles the per-row "Mark resolved" form POST. It marks
|
||||
// the row resolved (one-way) and fires the author thank-you email, then
|
||||
// 303-redirects back to /stats so a refresh doesn't re-submit. Already-
|
||||
// resolved or unknown ids redirect silently — the desired end state is
|
||||
// already true.
|
||||
func (h *StatsHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.svc.Resolve(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyResolved) {
|
||||
http.Error(w, "resolve failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/stats", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{
|
||||
"fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
|
||||
}).Parse(statsHTML))
|
||||
|
||||
const statsHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Stats · Prabhupāda Says</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
* { box-sizing: border-box }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; max-width: 920px; margin: 2.5rem auto; padding: 0 1.5rem; color: #1a1a1a; line-height: 1.5 }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.25rem }
|
||||
h2 { font-size: 1.05rem; margin-top: 2rem; margin-bottom: 0.5rem; border-bottom: 1px solid #e5e5e5; padding-bottom: 0.25rem }
|
||||
.meta { color: #888; font-size: 0.8rem; margin-bottom: 1.5rem }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.75rem }
|
||||
.card { background: #fafafa; border: 1px solid #ececec; border-radius: 8px; padding: 0.9rem 1rem }
|
||||
.card .num { font-size: 1.4rem; font-weight: 600; line-height: 1.1 }
|
||||
.card .num .delta { font-size: 0.75rem; font-weight: 500; color: #1e7a4a; margin-left: 0.4rem }
|
||||
.card .label { color: #666; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.35rem }
|
||||
.row { display: flex; justify-content: space-between; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #f0f0f0; font-size: 0.92rem }
|
||||
.row:last-child { border-bottom: 0 }
|
||||
.row .cat { font-weight: 600; text-transform: capitalize }
|
||||
.fb { background: #fafafa; border: 1px solid #ececec; border-radius: 8px; padding: 0.9rem 1rem; margin-bottom: 0.6rem }
|
||||
.fb.resolved { background: #f3f8f4; border-color: #d6e9da }
|
||||
.fb .head { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.82rem; color: #666; margin-bottom: 0.4rem; flex-wrap: wrap; align-items: center }
|
||||
.fb .cat-pill { font-weight: 700; text-transform: capitalize; color: #1a1a1a }
|
||||
.fb .msg { white-space: pre-wrap; font-size: 0.95rem }
|
||||
.fb .author { font-size: 0.78rem; color: #888; margin-top: 0.5rem }
|
||||
.fb .foot { display: flex; justify-content: flex-end; align-items: center; gap: 0.6rem; margin-top: 0.6rem }
|
||||
.resolved-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #1e7a4a }
|
||||
.acked-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #9a6b2f }
|
||||
.resolve-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #fff; background: #1e7a4a; border: 0; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
|
||||
.resolve-btn:hover { background: #196b40 }
|
||||
.ack-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #9a6b2f; background: #fff; border: 1px solid #d6b27a; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
|
||||
.ack-btn:hover { background: #faf5ea }
|
||||
code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size: 0.85em }
|
||||
.empty { color: #888; font-style: italic }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>System snapshot</h1>
|
||||
<p class="meta">Generated {{.GeneratedAt}}</p>
|
||||
|
||||
<h2>Users & activity</h2>
|
||||
<div class="grid">
|
||||
<div class="card"><div class="label">Users</div><div class="num">{{.Stats.Users}}<span class="delta">+{{.Stats.UsersLast7Days}} / 7d</span></div></div>
|
||||
<div class="card"><div class="label">Posts</div><div class="num">{{.Stats.Posts}}<span class="delta">+{{.Stats.PostsLast7Days}} / 7d</span></div></div>
|
||||
<div class="card"><div class="label">Collections</div><div class="num">{{.Stats.Collections}}<span class="delta">{{.Stats.PublicCollections}} public</span></div></div>
|
||||
<div class="card"><div class="label">Bookmarks</div><div class="num">{{.Stats.Bookmarks}}</div></div>
|
||||
<div class="card"><div class="label">Reshares</div><div class="num">{{.Stats.Reshares}}</div></div>
|
||||
<div class="card"><div class="label">Feedback</div><div class="num">{{.Stats.Feedback}}<span class="delta">+{{.Stats.FeedbackLast7Days}} / 7d</span></div></div>
|
||||
</div>
|
||||
|
||||
<h2>Feedback by category</h2>
|
||||
{{if .Stats.FeedbackByCategory}}
|
||||
{{range .Stats.FeedbackByCategory}}
|
||||
<div class="row"><span class="cat">{{.Category}}</span><span>{{.Count}}</span></div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="empty">No feedback yet.</p>
|
||||
{{end}}
|
||||
|
||||
<h2>Recent feedback</h2>
|
||||
{{if .Feedback}}
|
||||
{{range .Feedback}}
|
||||
<div class="fb{{if .ResolvedAt}} resolved{{end}}">
|
||||
<div class="head">
|
||||
<span class="cat-pill">{{.Category}}</span>
|
||||
<span>{{fmtTime .CreatedAt}}</span>
|
||||
</div>
|
||||
<div class="msg">{{.Message}}</div>
|
||||
<div class="author">
|
||||
{{if .AuthorHandle}}
|
||||
@{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}}
|
||||
{{else if .ContactEmail}}
|
||||
anonymous · <code>{{.ContactEmail}}</code>
|
||||
{{else}}
|
||||
anonymous
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="foot">
|
||||
{{if .ResolvedAt}}
|
||||
{{if .AcknowledgedAt}}<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>{{end}}
|
||||
<span class="resolved-badge">✓ Resolved {{fmtTime .ResolvedAt}}</span>
|
||||
{{else}}
|
||||
{{if .AcknowledgedAt}}
|
||||
<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>
|
||||
{{else}}
|
||||
<form method="post" action="/stats/feedback/{{.ID}}/acknowledge" onsubmit="return confirm('Acknowledge this feedback? The author will be emailed that we have seen it and started work. This cannot be undone.');">
|
||||
<button type="submit" class="ack-btn">Acknowledge</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/stats/feedback/{{.ID}}/resolve" onsubmit="return confirm('Mark this feedback as resolved? This cannot be undone, and the author will be emailed.');">
|
||||
<button type="submit" class="resolve-btn">Mark resolved</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="empty">No feedback yet.</p>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>`
|
||||
+137
-2
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
@@ -29,9 +30,20 @@ type updateProfileReq struct {
|
||||
Name *string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Handle *string `json:"handle"`
|
||||
Country *string `json:"country"`
|
||||
// Devotional profile (user-editable).
|
||||
Sampradaya *string `json:"sampradaya"`
|
||||
Gender *string `json:"gender"`
|
||||
Initiated *bool `json:"initiated"`
|
||||
InitiatedAt *string `json:"initiated_at"`
|
||||
DailyChantingRounds *int `json:"daily_chanting_rounds"`
|
||||
// System-managed notification fields — accepted here so future logic
|
||||
// can set them via the same endpoint; no UI sends them today.
|
||||
PreferredChantingTime *string `json:"preferred_chanting_time"`
|
||||
PeakActiveTime *string `json:"peak_active_time"`
|
||||
}
|
||||
|
||||
// UpdateProfile patches name, avatar, and/or handle.
|
||||
// UpdateProfile patches any subset of the editable profile fields.
|
||||
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
|
||||
@@ -41,7 +53,19 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.svc.UpdateProfile(r.Context(), uid, req.Name, req.AvatarURL, req.Handle)
|
||||
u, err := h.svc.UpdateProfile(r.Context(), uid, service.UpdateProfileInput{
|
||||
Name: req.Name,
|
||||
AvatarURL: req.AvatarURL,
|
||||
Handle: req.Handle,
|
||||
Country: req.Country,
|
||||
Sampradaya: req.Sampradaya,
|
||||
Gender: req.Gender,
|
||||
Initiated: req.Initiated,
|
||||
InitiatedAt: req.InitiatedAt,
|
||||
DailyChantingRounds: req.DailyChantingRounds,
|
||||
PreferredChantingTime: req.PreferredChantingTime,
|
||||
PeakActiveTime: req.PeakActiveTime,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrHandleTaken) {
|
||||
response.Error(w, http.StatusConflict, "that handle is already in use")
|
||||
@@ -53,6 +77,117 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
response.JSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
// userSearchItem is the public-safe slice of model.User returned by the
|
||||
// search endpoint. Sensitive fields (email, password hash, role, plan,
|
||||
// token_version, timestamps) are intentionally omitted.
|
||||
type userSearchItem struct {
|
||||
Handle string `json:"handle"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
}
|
||||
|
||||
// Search handles GET /api/v1/users/search?q=...&limit=...
|
||||
//
|
||||
// Public endpoint — anonymous and signed-in viewers alike can search
|
||||
// for users by handle or display name. Returns a sanitized payload
|
||||
// without auth-sensitive fields. Used by the global /search page when
|
||||
// the query carries an "@" prefix.
|
||||
func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
limit := 20
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if q == "" {
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"query": "",
|
||||
"results": []userSearchItem{},
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"limit": limit,
|
||||
"total_pages": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
page := 1
|
||||
if v := r.URL.Query().Get("page"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 1 {
|
||||
page = n
|
||||
}
|
||||
}
|
||||
|
||||
users, total, err := h.svc.Search(r.Context(), q, limit, (page-1)*limit)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "search failed")
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]userSearchItem, 0, len(users))
|
||||
for _, u := range users {
|
||||
items = append(items, userSearchItem{
|
||||
Handle: u.Handle,
|
||||
Name: u.Name,
|
||||
AvatarURL: u.AvatarURL,
|
||||
})
|
||||
}
|
||||
totalPages := (total + limit - 1) / limit
|
||||
response.JSON(w, http.StatusOK, map[string]any{
|
||||
"query": q,
|
||||
"results": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total_pages": totalPages,
|
||||
})
|
||||
}
|
||||
|
||||
// GetReadingPrefs returns the caller's saved reading prefs blob (always
|
||||
// well-formed JSON; an empty object when the user has never written).
|
||||
// The frontend treats the response as the source of truth on first
|
||||
// sign-in for this device.
|
||||
func (h *UserHandler) GetReadingPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
raw, err := h.svc.GetReadingPrefs(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load reading prefs")
|
||||
return
|
||||
}
|
||||
// Round-trip the JSONB as the data field directly. We use a raw
|
||||
// json.RawMessage so the body is `{"data": {...}}` not a re-encoded
|
||||
// string. The envelope wrapper keeps shape parity with the rest of
|
||||
// the API.
|
||||
response.JSON(w, http.StatusOK, json.RawMessage(raw))
|
||||
}
|
||||
|
||||
// UpdateReadingPrefs replaces the caller's reading-prefs blob with the
|
||||
// request body (which must itself be a JSON object — we re-marshal a
|
||||
// decoded map to enforce that without imposing a fixed schema on a
|
||||
// payload the frontend owns).
|
||||
func (h *UserHandler) UpdateReadingPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
|
||||
// Decode into map[string]any to guarantee the body is an object (not
|
||||
// an array, number, or string). Re-marshal before persisting so the
|
||||
// stored JSON is canonicalised and any duplicate keys are dropped.
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body (expect an object)")
|
||||
return
|
||||
}
|
||||
prefsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "could not re-encode prefs")
|
||||
return
|
||||
}
|
||||
if err := h.svc.UpdateReadingPrefs(r.Context(), uid, prefsJSON); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not save reading prefs")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, json.RawMessage(prefsJSON))
|
||||
}
|
||||
|
||||
type presignReq struct {
|
||||
ContentType string `json:"content_type"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// RequireAdmin gates a route to users whose role is "admin". It MUST run after
|
||||
// RequireAuth (it reads the authenticated user id from context, then looks up
|
||||
// the row to check the role). Non-admins get 403; this keeps the whole
|
||||
// /api/v1/admin/* surface invisible to ordinary accounts.
|
||||
func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
uid := UserID(r.Context())
|
||||
if uid == "" {
|
||||
response.Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
u, err := users.FindByID(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "auth check failed")
|
||||
return
|
||||
}
|
||||
if u.Role != "admin" {
|
||||
response.Error(w, http.StatusForbidden, "admin access required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,12 @@ func RequireAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Hand
|
||||
response.Error(w, http.StatusUnauthorized, "session expired — please log in again")
|
||||
return
|
||||
}
|
||||
// Suspended accounts are locked out of every protected route, so a
|
||||
// ban takes effect immediately for active sessions too.
|
||||
if u.BannedAt != nil {
|
||||
response.Error(w, http.StatusForbidden, "this account has been suspended")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
|
||||
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
|
||||
@@ -61,6 +67,39 @@ func RequireAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Hand
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth is the soft-auth sibling of RequireAuth. Same logic, but
|
||||
// invalid / missing sessions don't reject the request — they just skip
|
||||
// the context population. Use it on routes that are reachable
|
||||
// anonymously but want to personalise the response when the viewer
|
||||
// happens to be signed in (e.g. `viewer_has_reshared` on a public
|
||||
// post).
|
||||
func OptionalAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw := auth.Read(r)
|
||||
if raw == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
claims, err := issuer.Parse(raw)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
u, err := users.FindByID(r.Context(), claims.UserID)
|
||||
if err != nil || u.TokenVersion != claims.TokenVersion {
|
||||
// Stale or invalid session — proceed anonymously rather
|
||||
// than failing the request. RequireAuth would 401 here.
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
|
||||
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// UserID returns the authenticated user's id from the request context.
|
||||
// Returns "" if RequireAuth has not run.
|
||||
func UserID(ctx context.Context) string {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Admin DTOs — read-only projections served only to admins via /api/v1/admin/*.
|
||||
// They deliberately differ from the public-facing models: they may expose
|
||||
// fields (email, role, reporter identity) that the normal API hides.
|
||||
|
||||
// AdminUser is a row in the admin users table.
|
||||
type AdminUser struct {
|
||||
ID string `json:"id"`
|
||||
Email *string `json:"email"`
|
||||
Handle string `json:"handle"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Role string `json:"role"`
|
||||
Plan string `json:"plan"`
|
||||
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||
BannedAt *time.Time `json:"banned_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminPost is a row in the admin posts table.
|
||||
type AdminPost struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Caption string `json:"caption"`
|
||||
SourceTitle string `json:"source_title"`
|
||||
SourceURL string `json:"source_url"`
|
||||
IsHidden bool `json:"is_hidden"`
|
||||
DeletedAt *time.Time `json:"deleted_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorHandle string `json:"author_handle"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
}
|
||||
|
||||
// AdminCollection is a row in the admin collections table.
|
||||
type AdminCollection struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
ItemCount int `json:"item_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
OwnerHandle string `json:"owner_handle"`
|
||||
}
|
||||
|
||||
// AdminFeedback is a row in the admin feedback table (includes the reply).
|
||||
type AdminFeedback struct {
|
||||
ID string `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
ContactEmail *string `json:"contact_email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at"`
|
||||
RepliedAt *time.Time `json:"replied_at"`
|
||||
ReplyMessage *string `json:"reply_message"`
|
||||
AuthorHandle *string `json:"author_handle"`
|
||||
AuthorName *string `json:"author_name"`
|
||||
AuthorEmail *string `json:"author_email"`
|
||||
}
|
||||
|
||||
// AdminReport is a unified projection over the three report tables
|
||||
// (post_reports, comment_reports, user_reports). Type says which one.
|
||||
type AdminReport struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "post" | "comment" | "user"
|
||||
Category string `json:"category"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at"`
|
||||
|
||||
ReporterHandle *string `json:"reporter_handle"`
|
||||
ReporterName *string `json:"reporter_name"`
|
||||
|
||||
// Target of the report. TargetID is the post/comment/user id; TargetText
|
||||
// is the post caption, comment body, or reported user's handle so the
|
||||
// admin sees what was flagged without a second fetch. TargetAuthorHandle
|
||||
// is the handle of the content's author (nil for user reports).
|
||||
TargetID string `json:"target_id"`
|
||||
TargetText *string `json:"target_text"`
|
||||
TargetAuthorHandle *string `json:"target_author_handle"`
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Feedback is a user-submitted message — bug report, feature request,
|
||||
// or general comment — captured by the /feedback page. Surfaced to
|
||||
// admins both via the /stats page and via an email fan-out on insert.
|
||||
//
|
||||
// UserID is a pointer so the model can carry anonymous submissions
|
||||
// (logged-out visitors) and orphaned rows (the submitter has since
|
||||
// deleted their account — the FK is set null). ContactEmail is the
|
||||
// optional reply-to filled in by anonymous submitters.
|
||||
//
|
||||
// AuthorHandle / AuthorName / AuthorEmail are populated by joins in
|
||||
// the repo's lookup helpers (used by the admin email template and the
|
||||
// /stats page). They're nil for anonymous rows.
|
||||
type Feedback struct {
|
||||
ID string `json:"id"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
ContactEmail *string `json:"contact_email,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// AcknowledgedAt is set when an admin presses "Acknowledge" on the
|
||||
// /stats page — the team has seen it and (if it needs work) the devs
|
||||
// have started. NULL until then. One-way like ResolvedAt.
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||
// ResolvedAt is set the moment an admin presses "Mark resolved" on
|
||||
// the /stats page. NULL while open. The action is one-way — the
|
||||
// repo's MarkResolved guards on `resolved_at IS NULL` so a second
|
||||
// resolve is a no-op.
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||
|
||||
AuthorHandle *string `json:"author_handle,omitempty"`
|
||||
AuthorName *string `json:"author_name,omitempty"`
|
||||
AuthorEmail *string `json:"-"` // server-side only; never serialised
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import "time"
|
||||
// Post is a public quote on a user's feed. Caption is the verbatim selected
|
||||
// text from the knowledgebase; SourceURL links back to the page the user
|
||||
// was reading. Images are loaded separately via PostImage rows.
|
||||
//
|
||||
// Reshares are real Post rows owned by the resharer, with
|
||||
// ResharedFromPostID pointing to the canonical original. Likes, comments,
|
||||
// and images all hang off the post's own id, so they diverge naturally
|
||||
// across reshare boundaries — no parallel "per-resharer" tables.
|
||||
type Post struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
@@ -15,10 +20,88 @@ type Post struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// IsHidden — owner has hidden this post from timelines (their profile as
|
||||
// seen by others, the home feed) and from public collections. It still
|
||||
// shows to the owner on their own profile and in private collections.
|
||||
IsHidden bool `json:"is_hidden"`
|
||||
|
||||
// Author info — populated by repo joins for feed listings.
|
||||
AuthorName string `json:"author_name,omitempty"`
|
||||
AuthorHandle string `json:"author_handle,omitempty"`
|
||||
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
|
||||
|
||||
// Reshare attribution. ResharedFromPostID is non-nil when this post
|
||||
// is a reshare; the four `Original*` fields carry the canonical
|
||||
// post's author info so the frontend can render a
|
||||
// "Reshared from @handle" link without an extra round-trip.
|
||||
ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"`
|
||||
OriginalAuthorName *string `json:"original_author_name,omitempty"`
|
||||
OriginalAuthorHandle *string `json:"original_author_handle,omitempty"`
|
||||
OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"`
|
||||
|
||||
// ViewerHasReshared is true when the calling user has reshared the
|
||||
// canonical version of this post. Hydrated by the service layer
|
||||
// after the repo returns posts; always false for anonymous viewers.
|
||||
// Drives the post-card "Reshare ↔ Send" swap on the frontend.
|
||||
ViewerHasReshared bool `json:"viewer_has_reshared"`
|
||||
|
||||
// Likes. LikesCount is the effective total = the denormalized
|
||||
// posts.likes_count plus any still-pending Redis delta (hydrated by the
|
||||
// like layer). ViewerHasLiked reflects the calling user's membership row;
|
||||
// false for anonymous viewers.
|
||||
LikesCount int `json:"likes_count"`
|
||||
ViewerHasLiked bool `json:"viewer_has_liked"`
|
||||
|
||||
// MentionsCount is how many users are tagged on this post (drives the
|
||||
// post-card mentions icon). Hydrated by the mention layer.
|
||||
MentionsCount int `json:"mentions_count"`
|
||||
|
||||
// PinnedComment is the post's pinned comment (if any), hydrated by the
|
||||
// comment layer so the card footer can show it without a per-card fetch.
|
||||
// Nil when nothing is pinned.
|
||||
PinnedComment *Comment `json:"pinned_comment,omitempty"`
|
||||
|
||||
// CommentsCount is the total number of comments + replies on the post
|
||||
// (drives the footer's comment count). Hydrated by the comment layer.
|
||||
CommentsCount int `json:"comments_count"`
|
||||
|
||||
// Viewer↔author relationship, hydrated for the home feed so the card can
|
||||
// render a Follow / Follow Back affordance. ViewerFollowsAuthor: the
|
||||
// caller follows (accepted) this post's author. AuthorFollowsViewer: the
|
||||
// author follows the caller. Both false for anonymous viewers / own posts.
|
||||
ViewerFollowsAuthor bool `json:"viewer_follows_author"`
|
||||
AuthorFollowsViewer bool `json:"author_follows_viewer"`
|
||||
}
|
||||
|
||||
// Comment is one comment (or reply) on a post. Threading is one level deep:
|
||||
// ParentID is nil for a top-level comment and otherwise points at the thread
|
||||
// root. ReplyToHandle, when set, is the @handle the reply was addressed to
|
||||
// (replies stay flat under the root; this just preserves who was answered).
|
||||
type Comment struct {
|
||||
ID string `json:"id"`
|
||||
PostID string `json:"post_id"`
|
||||
UserID string `json:"user_id"`
|
||||
ParentID *string `json:"parent_id,omitempty"`
|
||||
Body string `json:"body"`
|
||||
LikesCount int `json:"likes_count"`
|
||||
PinnedAt *time.Time `json:"pinned_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Author info — populated by repo joins.
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorHandle string `json:"author_handle"`
|
||||
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
|
||||
|
||||
// ReplyToHandle is the handle this reply was addressed to (nil on
|
||||
// top-level comments), for the "Replying to @handle" prefix.
|
||||
ReplyToHandle *string `json:"reply_to_handle,omitempty"`
|
||||
|
||||
// ViewerHasLiked reflects the calling user's like membership; false for
|
||||
// anonymous viewers. ReplyCount is the number of replies under a
|
||||
// top-level comment (0 on replies themselves).
|
||||
ViewerHasLiked bool `json:"viewer_has_liked"`
|
||||
ReplyCount int `json:"reply_count"`
|
||||
}
|
||||
|
||||
// PostImage represents one attached image on a post.
|
||||
@@ -66,3 +149,113 @@ type PublicProfile struct {
|
||||
PostCount int `json:"post_count"`
|
||||
BookmarkCount int `json:"-"` // never serialized — private
|
||||
}
|
||||
|
||||
// RelationshipState is the viewer's relationship to a target profile,
|
||||
// computed fresh per request from the follows + blocks tables. Every
|
||||
// field is "from the viewer looking at the target":
|
||||
//
|
||||
// - Following: viewer → target follow edge is accepted.
|
||||
// - FollowRequested: viewer → target follow edge is pending (awaiting
|
||||
// the target's approval). The "Requested" button state.
|
||||
// - FollowsYou: target → viewer follow edge is accepted (they
|
||||
// already follow the viewer). Drives the "Follow back" label and
|
||||
// the "Remove from followers" menu item.
|
||||
// - RequestsYou: target → viewer follow edge is pending (the target
|
||||
// asked to follow the viewer). Drives the Accept / Reject buttons.
|
||||
// - Blocked: viewer has blocked the target.
|
||||
// - BlockedBy: target has blocked the viewer.
|
||||
//
|
||||
// nil (not this struct's zero value) is returned for anonymous viewers
|
||||
// and for a user viewing their own profile — callers special-case those.
|
||||
type RelationshipState struct {
|
||||
Following bool `json:"following"`
|
||||
FollowRequested bool `json:"follow_requested"`
|
||||
FollowsYou bool `json:"follows_you"`
|
||||
RequestsYou bool `json:"requests_you"`
|
||||
Blocked bool `json:"blocked"`
|
||||
BlockedBy bool `json:"blocked_by"`
|
||||
}
|
||||
|
||||
// Follow is one row of the follows table — a directed, request-based
|
||||
// edge from Follower to Following. Status is "pending" until the target
|
||||
// accepts.
|
||||
type Follow struct {
|
||||
ID string `json:"id"`
|
||||
FollowerID string `json:"follower_id"`
|
||||
FollowingID string `json:"following_id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RespondedAt *time.Time `json:"responded_at,omitempty"`
|
||||
}
|
||||
|
||||
// UserBlock is one row of user_blocks — Blocker has blocked Blocked.
|
||||
type UserBlock struct {
|
||||
ID string `json:"id"`
|
||||
BlockerID string `json:"blocker_id"`
|
||||
BlockedID string `json:"blocked_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// UserReport is one row of user_reports — Reporter flagged Reported.
|
||||
type UserReport struct {
|
||||
ID string `json:"id"`
|
||||
ReporterID string `json:"reporter_id"`
|
||||
ReportedID string `json:"reported_id"`
|
||||
Category string `json:"category"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||
}
|
||||
|
||||
// PostReport is one row of post_reports — Reporter flagged a specific
|
||||
// post. Reason is optional ("" when none given).
|
||||
type PostReport struct {
|
||||
ID string `json:"id"`
|
||||
ReporterID string `json:"reporter_id"`
|
||||
PostID string `json:"post_id"`
|
||||
Category string `json:"category"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||
}
|
||||
|
||||
// Notification is one delivered/queued notification for a recipient
|
||||
// (UserID). ActorID is who triggered it; Kind is a short machine string.
|
||||
// Hydrated Actor* fields are filled by repo joins for list rendering.
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
ActorID *string `json:"actor_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||
|
||||
// Aggregation: for post_like, PostID is the liked post and ActorCount is
|
||||
// how many likers have aggregated into this single row (the frontend
|
||||
// renders "X and N-1 others liked your post"). Other kinds: PostID nil,
|
||||
// ActorCount 1.
|
||||
PostID *string `json:"post_id,omitempty"`
|
||||
ActorCount int `json:"actor_count"`
|
||||
|
||||
ActorName *string `json:"actor_name,omitempty"`
|
||||
ActorHandle *string `json:"actor_handle,omitempty"`
|
||||
ActorAvatar *string `json:"actor_avatar_url,omitempty"`
|
||||
|
||||
// PostAuthorHandle is the handle of the author of PostID (when set), so
|
||||
// the client can deep-link to the post for kinds where the actor isn't
|
||||
// the post owner (e.g. comment_reply). Nil when there's no post.
|
||||
PostAuthorHandle *string `json:"post_author_handle,omitempty"`
|
||||
|
||||
// Body carries free text for the 'admin_message' kind (an admin → user
|
||||
// direct message). Nil for every event-driven kind.
|
||||
Body *string `json:"body,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"`
|
||||
}
|
||||
|
||||
+90
-4
@@ -1,28 +1,94 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User represents a row in the `users` table.
|
||||
//
|
||||
// PasswordHash is tagged `json:"-"` so it never leaks via JSON marshalling
|
||||
// even if a caller accidentally passes a User through response.JSON.
|
||||
// even if a caller accidentally passes a User through response.JSON. It's a
|
||||
// pointer because OAuth-only users have no password — *PasswordHash == nil
|
||||
// means "this account can only sign in via a linked OAuth provider".
|
||||
//
|
||||
// UserSummary is the lightweight projection used in follower / following
|
||||
// lists — just enough to render a row and link to the profile.
|
||||
type UserSummary struct {
|
||||
// ID is populated only where the caller needs it (e.g. mention add/remove);
|
||||
// omitted from follower/like lists that link by handle.
|
||||
ID string `json:"id,omitempty"`
|
||||
Handle string `json:"handle"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
// EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null`
|
||||
// when the user has not yet verified, instead of an omitted field — the
|
||||
// frontend uses this to decide whether to show the "verify your email" ribbon.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
// Email is nullable: OAuth users (typically Facebook accounts with no
|
||||
// verified email on file) sign up without one and can fill it in later
|
||||
// from their profile.
|
||||
Email *string `json:"email"`
|
||||
Handle string `json:"handle"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Role string `json:"role"`
|
||||
Plan string `json:"plan"` // "free" | "premium"
|
||||
// Country is the account's ISO-3166-1 alpha-2 code (e.g. "IN"),
|
||||
// captured best-effort at signup and editable from the profile form.
|
||||
// nil until known.
|
||||
Country *string `json:"country"`
|
||||
|
||||
// --- Devotional profile (user-editable) ---
|
||||
// Gender is a free-but-constrained string ("male"|"female"|"other"),
|
||||
// nil when unspecified.
|
||||
Gender *string `json:"gender"`
|
||||
// Sampradaya is the devotee's spiritual lineage (a lowercase code,
|
||||
// e.g. "gaudiya"). Optional — new accounts default to "gaudiya" but it
|
||||
// can be cleared, so nil means "not specified".
|
||||
Sampradaya *string `json:"sampradaya"`
|
||||
// Initiated marks whether the devotee has received initiation.
|
||||
Initiated bool `json:"initiated"`
|
||||
// InitiatedAt is the initiation date (DATE column), nil when unknown or
|
||||
// not initiated.
|
||||
InitiatedAt *time.Time `json:"initiated_at"`
|
||||
// DailyChantingRounds is the devotee's daily japa round vow (1–1008),
|
||||
// nil when unspecified.
|
||||
DailyChantingRounds *int `json:"daily_chanting_rounds"`
|
||||
|
||||
// --- Notification scheduling (system-managed; no UI yet) ---
|
||||
// PreferredChantingTime / PeakActiveTime store a local clock time as
|
||||
// "HH:MM". They're written silently by the backend to time automated
|
||||
// notifications; the scheduling logic ships later. nil until computed.
|
||||
PreferredChantingTime *string `json:"preferred_chanting_time"`
|
||||
PeakActiveTime *string `json:"peak_active_time"`
|
||||
|
||||
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||
PasswordHash string `json:"-"`
|
||||
PasswordHash *string `json:"-"`
|
||||
TokenVersion int `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// BannedAt suspends the account when set: RequireAuth rejects the session
|
||||
// and LogIn refuses to issue a new one. nil = in good standing.
|
||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
|
||||
// "Change password" vs "Set a password" for OAuth-only accounts, without
|
||||
// the password hash itself ever leaving the server.
|
||||
func (u User) MarshalJSON() ([]byte, error) {
|
||||
type alias User
|
||||
return json.Marshal(&struct {
|
||||
alias
|
||||
HasPassword bool `json:"has_password"`
|
||||
}{
|
||||
alias: alias(u),
|
||||
HasPassword: u.PasswordHash != nil,
|
||||
})
|
||||
}
|
||||
|
||||
// AuthToken represents a row in the `auth_tokens` table.
|
||||
@@ -40,8 +106,28 @@ type AuthToken struct {
|
||||
const (
|
||||
PurposePasswordReset = "password_reset"
|
||||
PurposeEmailVerification = "email_verification"
|
||||
// PurposeLoginOTP is a 6-digit one-time code emailed to a user whose
|
||||
// account has been unverified for >30 days. Verification happens
|
||||
// per-user (FindValidForUser), so the 1M-keyspace can't be brute-forced
|
||||
// offline by hash collision — only online against rate limits.
|
||||
PurposeLoginOTP = "login_otp"
|
||||
)
|
||||
|
||||
// OAuthAccount represents a row in the `oauth_accounts` table — one entry
|
||||
// per (provider, provider_user_id). A single user can have several linked
|
||||
// providers.
|
||||
type OAuthAccount struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderUserID string `json:"provider_user_id"`
|
||||
Email *string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ShortURL represents a shortened URL.
|
||||
type ShortURL struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// AdminRepo holds the read-mostly queries that back the admin panel. Every
|
||||
// list method returns its page plus the unfiltered total (via COUNT(*) OVER())
|
||||
// so the UI can paginate without a second round-trip. All queries are new —
|
||||
// they read existing tables but don't alter any existing repo behaviour.
|
||||
type AdminRepo struct{ pg *Postgres }
|
||||
|
||||
func NewAdminRepo(pg *Postgres) *AdminRepo { return &AdminRepo{pg: pg} }
|
||||
|
||||
// clampLimit normalises pagination so a bad client can't ask for everything.
|
||||
func clampLimit(limit int) int {
|
||||
if limit <= 0 {
|
||||
return 25
|
||||
}
|
||||
if limit > 100 {
|
||||
return 100
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// ListUsers returns a page of users filtered by an optional query (matched
|
||||
// against name, handle, or email), newest first, with the total count.
|
||||
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
||||
limit = clampLimit(limit)
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT id, email, handle, name, avatar_url, role, plan, email_verified_at, banned_at, created_at,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM users
|
||||
WHERE ($1 = '' OR name ILIKE '%'||$1||'%' OR handle ILIKE '%'||$1||'%'
|
||||
OR COALESCE(email, '') ILIKE '%'||$1||'%')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("admin list users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []model.AdminUser{}
|
||||
total := 0
|
||||
for rows.Next() {
|
||||
var u model.AdminUser
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Handle, &u.Name, &u.AvatarURL,
|
||||
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.CreatedAt, &total); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan admin user: %w", err)
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// UserExists reports whether a user id is real (used before sending a
|
||||
// notification to it).
|
||||
func (r *AdminRepo) UserExists(ctx context.Context, id string) (bool, error) {
|
||||
var exists bool
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, id).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("admin user exists: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// ListPosts returns a page of posts filtered by an optional query (caption,
|
||||
// source title, or author handle), newest first, with the total count.
|
||||
func (r *AdminRepo) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) {
|
||||
limit = clampLimit(limit)
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT p.id, p.user_id, p.caption, p.source_title, p.source_url, p.is_hidden, p.deleted_at, p.created_at,
|
||||
u.name, u.handle, p.likes_count,
|
||||
(SELECT COUNT(*) FROM post_comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL) AS comments_count,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM posts p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE ($1 = '' OR p.caption ILIKE '%'||$1||'%' OR p.source_title ILIKE '%'||$1||'%'
|
||||
OR u.handle ILIKE '%'||$1||'%')
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("admin list posts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []model.AdminPost{}
|
||||
total := 0
|
||||
for rows.Next() {
|
||||
var p model.AdminPost
|
||||
if err := rows.Scan(&p.ID, &p.UserID, &p.Caption, &p.SourceTitle, &p.SourceURL,
|
||||
&p.IsHidden, &p.DeletedAt, &p.CreatedAt, &p.AuthorName, &p.AuthorHandle,
|
||||
&p.LikesCount, &p.CommentsCount, &total); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan admin post: %w", err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// ListCollections returns a page of collections filtered by an optional query
|
||||
// (title, description, or owner handle), newest first, with the total count.
|
||||
func (r *AdminRepo) ListCollections(ctx context.Context, q string, limit, offset int) ([]model.AdminCollection, int, error) {
|
||||
limit = clampLimit(limit)
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT c.id, c.user_id, c.title, c.description, c.is_public, c.created_at,
|
||||
(SELECT COUNT(*) FROM collection_items ci WHERE ci.collection_id = c.id) AS item_count,
|
||||
u.name, u.handle,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM collections c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE ($1 = '' OR c.title ILIKE '%'||$1||'%' OR COALESCE(c.description, '') ILIKE '%'||$1||'%'
|
||||
OR u.handle ILIKE '%'||$1||'%')
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("admin list collections: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []model.AdminCollection{}
|
||||
total := 0
|
||||
for rows.Next() {
|
||||
var c model.AdminCollection
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.IsPublic,
|
||||
&c.CreatedAt, &c.ItemCount, &c.OwnerName, &c.OwnerHandle, &total); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan admin collection: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// ListFeedback returns a page of feedback. status is "all" | "open" |
|
||||
// "resolved". Newest first, with the total count for the chosen filter.
|
||||
func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offset int) ([]model.AdminFeedback, int, error) {
|
||||
limit = clampLimit(limit)
|
||||
if status != "open" && status != "resolved" {
|
||||
status = "all"
|
||||
}
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT f.id, f.category, f.message, f.contact_email, f.created_at,
|
||||
f.resolved_at, f.replied_at, f.reply_message,
|
||||
u.handle, u.name, u.email,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM feedback f
|
||||
LEFT JOIN users u ON u.id = f.user_id
|
||||
WHERE ($1 = 'all'
|
||||
OR ($1 = 'open' AND f.resolved_at IS NULL)
|
||||
OR ($1 = 'resolved' AND f.resolved_at IS NOT NULL))
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $2 OFFSET $3`, status, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("admin list feedback: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []model.AdminFeedback{}
|
||||
total := 0
|
||||
for rows.Next() {
|
||||
var f model.AdminFeedback
|
||||
if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||
&f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
|
||||
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// SaveReply stores an admin reply on a feedback row, stamps replied_at, and
|
||||
// resolves it (if not already). It returns the recipient's email (the
|
||||
// submitter's account email, else the anonymous contact_email — may be nil if
|
||||
// neither is known) and name so the caller can send the reply email. ok is
|
||||
// false when the id doesn't exist.
|
||||
func (r *AdminRepo) SaveReply(ctx context.Context, id, message string) (email *string, name string, ok bool, err error) {
|
||||
err = r.pg.Pool.QueryRow(ctx, `
|
||||
UPDATE feedback f
|
||||
SET reply_message = $2,
|
||||
replied_at = now(),
|
||||
resolved_at = COALESCE(resolved_at, now())
|
||||
WHERE f.id = $1
|
||||
RETURNING COALESCE((SELECT email FROM users WHERE id = f.user_id), f.contact_email),
|
||||
COALESCE((SELECT name FROM users WHERE id = f.user_id), '')`,
|
||||
id, message,
|
||||
).Scan(&email, &name)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", false, fmt.Errorf("admin save feedback reply: %w", err)
|
||||
}
|
||||
return email, name, true, nil
|
||||
}
|
||||
|
||||
// ListReports returns a page of reports of one type ("post" | "comment" |
|
||||
// "user"), unresolved first then newest, filtered by an optional query, with
|
||||
// the total count.
|
||||
func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit, offset int) ([]model.AdminReport, int, error) {
|
||||
limit = clampLimit(limit)
|
||||
var sql string
|
||||
switch reportType {
|
||||
case "post":
|
||||
sql = `
|
||||
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
||||
ru.handle, ru.name,
|
||||
r.post_id, p.caption, pu.handle,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM post_reports r
|
||||
LEFT JOIN users ru ON ru.id = r.reporter_id
|
||||
LEFT JOIN posts p ON p.id = r.post_id
|
||||
LEFT JOIN users pu ON pu.id = p.user_id
|
||||
WHERE ($1 = '' OR COALESCE(p.caption, '') ILIKE '%'||$1||'%'
|
||||
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%'
|
||||
OR COALESCE(pu.handle, '') ILIKE '%'||$1||'%')
|
||||
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
|
||||
LIMIT $2 OFFSET $3`
|
||||
case "comment":
|
||||
sql = `
|
||||
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
||||
ru.handle, ru.name,
|
||||
r.comment_id, c.body, cu.handle,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM comment_reports r
|
||||
LEFT JOIN users ru ON ru.id = r.reporter_id
|
||||
LEFT JOIN post_comments c ON c.id = r.comment_id
|
||||
LEFT JOIN users cu ON cu.id = c.user_id
|
||||
WHERE ($1 = '' OR COALESCE(c.body, '') ILIKE '%'||$1||'%'
|
||||
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%'
|
||||
OR COALESCE(cu.handle, '') ILIKE '%'||$1||'%')
|
||||
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
|
||||
LIMIT $2 OFFSET $3`
|
||||
case "user":
|
||||
sql = `
|
||||
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
||||
ru.handle, ru.name,
|
||||
r.reported_id, tu.handle, tu.handle
|
||||
, COUNT(*) OVER() AS total
|
||||
FROM user_reports r
|
||||
LEFT JOIN users ru ON ru.id = r.reporter_id
|
||||
LEFT JOIN users tu ON tu.id = r.reported_id
|
||||
WHERE ($1 = '' OR COALESCE(tu.handle, '') ILIKE '%'||$1||'%'
|
||||
OR COALESCE(tu.name, '') ILIKE '%'||$1||'%'
|
||||
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%')
|
||||
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
|
||||
LIMIT $2 OFFSET $3`
|
||||
default:
|
||||
return nil, 0, fmt.Errorf("unknown report type %q", reportType)
|
||||
}
|
||||
|
||||
rows, err := r.pg.Pool.Query(ctx, sql, q, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("admin list %s reports: %w", reportType, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []model.AdminReport{}
|
||||
total := 0
|
||||
for rows.Next() {
|
||||
rep := model.AdminReport{Type: reportType}
|
||||
if err := rows.Scan(&rep.ID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt,
|
||||
&rep.ReporterHandle, &rep.ReporterName,
|
||||
&rep.TargetID, &rep.TargetText, &rep.TargetAuthorHandle, &total); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan admin report: %w", err)
|
||||
}
|
||||
out = append(out, rep)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// ResolveReport stamps resolved_at=now() on a still-open report of the given
|
||||
// type. Idempotent and one-way (the WHERE guard). Returns true when this call
|
||||
// is the one that resolved it.
|
||||
func (r *AdminRepo) ResolveReport(ctx context.Context, reportType, id string) (bool, error) {
|
||||
var table string
|
||||
switch reportType {
|
||||
case "post":
|
||||
table = "post_reports"
|
||||
case "comment":
|
||||
table = "comment_reports"
|
||||
case "user":
|
||||
table = "user_reports"
|
||||
default:
|
||||
return false, fmt.Errorf("unknown report type %q", reportType)
|
||||
}
|
||||
// table is from a fixed whitelist above — safe to interpolate.
|
||||
tag, err := r.pg.Pool.Exec(ctx,
|
||||
fmt.Sprintf(`UPDATE %s SET resolved_at = now() WHERE id = $1 AND resolved_at IS NULL`, table),
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("admin resolve %s report: %w", reportType, err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// ─── Moderation actions ──────────────────────────────────────────────────────
|
||||
|
||||
// SetPostHidden hides/unhides any post (admin override — not owner-scoped).
|
||||
// Returns false if the id doesn't exist.
|
||||
func (r *AdminRepo) SetPostHidden(ctx context.Context, postID string, hidden bool) (bool, error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE posts SET is_hidden = $2, updated_at = now() WHERE id = $1`, postID, hidden)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("admin set post hidden: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// SetPostDeleted soft-deletes or restores a post by stamping/clearing
|
||||
// deleted_at. A soft-deleted post is filtered out of every read path (feeds,
|
||||
// profile, hidden tab, collections, detail) but kept in the table so it can be
|
||||
// restored. Returns false if the id doesn't exist.
|
||||
func (r *AdminRepo) SetPostDeleted(ctx context.Context, postID string, deleted bool) (bool, error) {
|
||||
var sql string
|
||||
if deleted {
|
||||
sql = `UPDATE posts SET deleted_at = now(), updated_at = now() WHERE id = $1`
|
||||
} else {
|
||||
sql = `UPDATE posts SET deleted_at = NULL, updated_at = now() WHERE id = $1`
|
||||
}
|
||||
tag, err := r.pg.Pool.Exec(ctx, sql, postID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("admin set post deleted: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// SetCommentDeleted soft-deletes or restores a comment (same semantics as
|
||||
// SetPostDeleted). Returns false if the id doesn't exist.
|
||||
func (r *AdminRepo) SetCommentDeleted(ctx context.Context, commentID string, deleted bool) (bool, error) {
|
||||
var sql string
|
||||
if deleted {
|
||||
sql = `UPDATE post_comments SET deleted_at = now(), updated_at = now() WHERE id = $1`
|
||||
} else {
|
||||
sql = `UPDATE post_comments SET deleted_at = NULL, updated_at = now() WHERE id = $1`
|
||||
}
|
||||
tag, err := r.pg.Pool.Exec(ctx, sql, commentID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("admin set comment deleted: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// SetUserBanned suspends/un-suspends an account. Banning also bumps
|
||||
// token_version, which invalidates the user's existing JWTs so the lock-out is
|
||||
// immediate (RequireAuth additionally rejects banned users). Returns false if
|
||||
// the id doesn't exist.
|
||||
func (r *AdminRepo) SetUserBanned(ctx context.Context, userID string, banned bool) (bool, error) {
|
||||
var sql string
|
||||
if banned {
|
||||
sql = `UPDATE users SET banned_at = now(), token_version = token_version + 1, updated_at = now() WHERE id = $1`
|
||||
} else {
|
||||
sql = `UPDATE users SET banned_at = NULL, updated_at = now() WHERE id = $1`
|
||||
}
|
||||
tag, err := r.pg.Pool.Exec(ctx, sql, userID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("admin set user banned: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
@@ -200,15 +200,18 @@ func (r *CollectionRepo) CollectionsContaining(ctx context.Context, postID, user
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// PostsIn returns the posts (with images + author) inside a collection.
|
||||
// PostsIn returns the posts inside a collection, ordered by collection
|
||||
// position then added_at. Images are NOT hydrated — call
|
||||
// PostRepo.ImagesFor on the returned ids if your caller renders the
|
||||
// post visually. The CollectionService.Get path already does this.
|
||||
func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]model.Post, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
|
||||
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url, p.is_hidden
|
||||
FROM collection_items ci
|
||||
JOIN posts p ON p.id = ci.post_id
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE ci.collection_id = $1
|
||||
WHERE ci.collection_id = $1 AND p.deleted_at IS NULL
|
||||
ORDER BY ci.position, ci.added_at`,
|
||||
collectionID,
|
||||
)
|
||||
@@ -224,7 +227,7 @@ func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]mo
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
||||
&p.CreatedAt, &p.UpdatedAt,
|
||||
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL, &p.IsHidden,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan post: %w", err)
|
||||
}
|
||||
@@ -234,8 +237,6 @@ func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]mo
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate collection posts: %w", err)
|
||||
}
|
||||
// Defer image hydration to caller via PostRepo if needed; for v1 we just
|
||||
// return without images here (the management page can fetch separately).
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrCommentNotFound — no comment with that id (or not in the expected
|
||||
// scope, e.g. pinning a reply).
|
||||
ErrCommentNotFound = errors.New("comment not found")
|
||||
// ErrDuplicateComment — the same user re-posted identical text in the same
|
||||
// scope (caught by the dedup unique index).
|
||||
ErrDuplicateComment = errors.New("duplicate comment")
|
||||
)
|
||||
|
||||
// CommentRepo handles post comments, replies, comment-likes, and pinning.
|
||||
// Comment likes are far lower-volume than post likes, so likes_count is kept
|
||||
// in sync transactionally rather than batched through Redis.
|
||||
type CommentRepo struct {
|
||||
pg *Postgres
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewCommentRepo(pg *Postgres, logger *zap.Logger) *CommentRepo {
|
||||
return &CommentRepo{pg: pg, logger: logger}
|
||||
}
|
||||
|
||||
// nullUUIDArg returns nil for an empty id so a `$n::uuid` parameter binds to
|
||||
// SQL NULL (anonymous viewer) instead of failing uuid parsing on "".
|
||||
func nullUUIDArg(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// commentSelect is the shared column list for reading a comment with its
|
||||
// author, the reply-to handle, the viewer's like state, and the reply count.
|
||||
// `vl` is a viewer-scoped LEFT JOIN on comment_likes; `ru` the reply-to user.
|
||||
const commentSelect = `
|
||||
c.id, c.post_id, c.user_id, c.parent_id, c.body, c.likes_count,
|
||||
c.pinned_at, c.created_at, c.updated_at,
|
||||
u.name, u.handle, u.avatar_url,
|
||||
ru.handle,
|
||||
(vl.comment_id IS NOT NULL),
|
||||
(SELECT count(*) FROM post_comments rc WHERE rc.parent_id = c.id AND rc.deleted_at IS NULL)`
|
||||
|
||||
// The viewer id always rides in parameter slot $2 across the read queries
|
||||
// below, so the shared viewer-scoped like join can hardcode it.
|
||||
const commentFrom = `
|
||||
FROM post_comments c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
LEFT JOIN users ru ON ru.id = c.reply_to_user_id
|
||||
LEFT JOIN comment_likes vl ON vl.comment_id = c.id AND vl.user_id = $2::uuid`
|
||||
|
||||
func scanComment(row pgx.Row, c *model.Comment) error {
|
||||
return row.Scan(&c.ID, &c.PostID, &c.UserID, &c.ParentID, &c.Body, &c.LikesCount,
|
||||
&c.PinnedAt, &c.CreatedAt, &c.UpdatedAt,
|
||||
&c.AuthorName, &c.AuthorHandle, &c.AuthorAvatarURL,
|
||||
&c.ReplyToHandle,
|
||||
&c.ViewerHasLiked, &c.ReplyCount)
|
||||
}
|
||||
|
||||
func scanComments(rows pgx.Rows) ([]model.Comment, error) {
|
||||
defer rows.Close()
|
||||
out := []model.Comment{}
|
||||
for rows.Next() {
|
||||
var c model.Comment
|
||||
if err := scanComment(rows, &c); err != nil {
|
||||
return nil, fmt.Errorf("scan comment: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PostAuthor returns the id of the user who owns the post, or ErrPostNotFound.
|
||||
func (r *CommentRepo) PostAuthor(ctx context.Context, postID string) (string, error) {
|
||||
var uid string
|
||||
err := r.pg.Pool.QueryRow(ctx, `SELECT user_id FROM posts WHERE id = $1`, postID).Scan(&uid)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrPostNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("post author: %w", err)
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
// Create inserts a comment. parentID is the thread root (nil for a top-level
|
||||
// comment); replyToUserID is who a reply addresses (nil for top-level).
|
||||
// Returns ErrDuplicateComment when the same user re-posts identical text in
|
||||
// the same scope. The returned Comment carries author fields.
|
||||
func (r *CommentRepo) Create(ctx context.Context, postID, userID string, parentID, replyToUserID *string, body string) (*model.Comment, error) {
|
||||
var id string
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO post_comments (post_id, user_id, parent_id, reply_to_user_id, body)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (post_id, user_id, COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), md5(body))
|
||||
DO NOTHING
|
||||
RETURNING id`,
|
||||
postID, userID, parentID, replyToUserID, body,
|
||||
).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrDuplicateComment
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert comment: %w", err)
|
||||
}
|
||||
// The author hasn't liked their own brand-new comment, so viewer state is
|
||||
// resolved against userID (yields false) — fine for the create response.
|
||||
return r.Get(ctx, id, userID)
|
||||
}
|
||||
|
||||
// Get returns one comment hydrated with author + viewer-like + reply count.
|
||||
func (r *CommentRepo) Get(ctx context.Context, id, viewerID string) (*model.Comment, error) {
|
||||
q := `SELECT ` + commentSelect + commentFrom + ` WHERE c.id = $1 AND c.deleted_at IS NULL`
|
||||
c := &model.Comment{}
|
||||
err := scanComment(r.pg.Pool.QueryRow(ctx, q, id, nullUUIDArg(viewerID)), c)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrCommentNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get comment: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListTopLevel returns a page of a post's top-level comments — pinned first,
|
||||
// then newest — plus the total top-level count.
|
||||
func (r *CommentRepo) ListTopLevel(ctx context.Context, postID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM post_comments WHERE post_id = $1 AND parent_id IS NULL AND deleted_at IS NULL`,
|
||||
postID).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count comments: %w", err)
|
||||
}
|
||||
if total == 0 {
|
||||
return []model.Comment{}, 0, nil
|
||||
}
|
||||
q := `SELECT ` + commentSelect + commentFrom + `
|
||||
WHERE c.post_id = $1 AND c.parent_id IS NULL AND c.deleted_at IS NULL
|
||||
ORDER BY (c.pinned_at IS NOT NULL) DESC, c.pinned_at DESC NULLS LAST, c.created_at DESC
|
||||
LIMIT $3 OFFSET $4`
|
||||
rows, err := r.pg.Pool.Query(ctx, q, postID, nullUUIDArg(viewerID), limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list comments: %w", err)
|
||||
}
|
||||
out, err := scanComments(rows)
|
||||
return out, total, err
|
||||
}
|
||||
|
||||
// ListReplies returns a page of a thread's replies (oldest first) + the total.
|
||||
func (r *CommentRepo) ListReplies(ctx context.Context, parentID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM post_comments WHERE parent_id = $1 AND deleted_at IS NULL`, parentID).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count replies: %w", err)
|
||||
}
|
||||
if total == 0 {
|
||||
return []model.Comment{}, 0, nil
|
||||
}
|
||||
q := `SELECT ` + commentSelect + commentFrom + `
|
||||
WHERE c.parent_id = $1 AND c.deleted_at IS NULL
|
||||
ORDER BY c.created_at ASC
|
||||
LIMIT $3 OFFSET $4`
|
||||
rows, err := r.pg.Pool.Query(ctx, q, parentID, nullUUIDArg(viewerID), limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list replies: %w", err)
|
||||
}
|
||||
out, err := scanComments(rows)
|
||||
return out, total, err
|
||||
}
|
||||
|
||||
// Hydrate fills each post's comment summary — its pinned comment (if any) and
|
||||
// total comment count (top-level + replies) — in two batched queries for the
|
||||
// page. Drives the card footer's pinned line and comment count.
|
||||
func (r *CommentRepo) Hydrate(ctx context.Context, viewerID string, posts []*model.Post) error {
|
||||
ids := make([]string, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if p != nil {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pinned comment per post.
|
||||
q := `SELECT ` + commentSelect + commentFrom + `
|
||||
WHERE c.post_id = ANY($1) AND c.pinned_at IS NOT NULL`
|
||||
rows, err := r.pg.Pool.Query(ctx, q, ids, nullUUIDArg(viewerID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("hydrate pinned: %w", err)
|
||||
}
|
||||
list, err := scanComments(rows) // closes rows
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pinnedByPost := make(map[string]*model.Comment, len(list))
|
||||
for i := range list {
|
||||
pinnedByPost[list[i].PostID] = &list[i]
|
||||
}
|
||||
|
||||
// Total comment count per post (top-level + replies).
|
||||
counts := make(map[string]int, len(ids))
|
||||
crows, err := r.pg.Pool.Query(ctx,
|
||||
`SELECT post_id, count(*) FROM post_comments WHERE post_id = ANY($1) AND deleted_at IS NULL GROUP BY post_id`,
|
||||
ids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hydrate comment counts: %w", err)
|
||||
}
|
||||
defer crows.Close()
|
||||
for crows.Next() {
|
||||
var id string
|
||||
var n int
|
||||
if err := crows.Scan(&id, &n); err != nil {
|
||||
return fmt.Errorf("scan comment count: %w", err)
|
||||
}
|
||||
counts[id] = n
|
||||
}
|
||||
if err := crows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range posts {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if c, ok := pinnedByPost[p.ID]; ok {
|
||||
p.PinnedComment = c
|
||||
}
|
||||
p.CommentsCount = counts[p.ID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Like / Unlike toggle the viewer's like on a comment and return the new
|
||||
// likes_count. Idempotent; the membership change and counter update happen in
|
||||
// one transaction so concurrent likes stay consistent.
|
||||
func (r *CommentRepo) Like(ctx context.Context, commentID, userID string) (int, error) {
|
||||
return r.setLike(ctx, commentID, userID, true)
|
||||
}
|
||||
|
||||
func (r *CommentRepo) Unlike(ctx context.Context, commentID, userID string) (int, error) {
|
||||
return r.setLike(ctx, commentID, userID, false)
|
||||
}
|
||||
|
||||
func (r *CommentRepo) setLike(ctx context.Context, commentID, userID string, like bool) (int, error) {
|
||||
tx, err := r.pg.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin like txn: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Lock the comment row up front: this validates existence (clean 404
|
||||
// instead of a leaked FK violation) and serializes concurrent likes so
|
||||
// the counter stays exact.
|
||||
var count int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT likes_count FROM post_comments WHERE id = $1 FOR UPDATE`, commentID).Scan(&count); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, ErrCommentNotFound
|
||||
}
|
||||
return 0, fmt.Errorf("read comment likes_count: %w", err)
|
||||
}
|
||||
|
||||
if like {
|
||||
tag, err := tx.Exec(ctx,
|
||||
`INSERT INTO comment_likes (comment_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
commentID, userID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert comment like: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
count++
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE post_comments SET likes_count = $2 WHERE id = $1`, commentID, count); err != nil {
|
||||
return 0, fmt.Errorf("bump comment likes: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tag, err := tx.Exec(ctx,
|
||||
`DELETE FROM comment_likes WHERE comment_id = $1 AND user_id = $2`, commentID, userID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete comment like: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
if count > 0 {
|
||||
count--
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE post_comments SET likes_count = $2 WHERE id = $1`, commentID, count); err != nil {
|
||||
return 0, fmt.Errorf("drop comment likes: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, fmt.Errorf("commit like txn: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Pin makes commentID the post's single pinned comment (top-level only),
|
||||
// unpinning any previously pinned comment in the same transaction. Returns
|
||||
// ErrCommentNotFound when the comment isn't a top-level comment of postID.
|
||||
func (r *CommentRepo) Pin(ctx context.Context, postID, commentID string) error {
|
||||
tx, err := r.pg.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin pin txn: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE post_comments SET pinned_at = NULL WHERE post_id = $1 AND pinned_at IS NOT NULL`,
|
||||
postID); err != nil {
|
||||
return fmt.Errorf("unpin existing: %w", err)
|
||||
}
|
||||
tag, err := tx.Exec(ctx,
|
||||
`UPDATE post_comments SET pinned_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND post_id = $2 AND parent_id IS NULL`,
|
||||
commentID, postID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pin comment: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrCommentNotFound
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Unpin clears the pin on commentID for postID.
|
||||
func (r *CommentRepo) Unpin(ctx context.Context, postID, commentID string) error {
|
||||
tag, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE post_comments SET pinned_at = NULL WHERE id = $1 AND post_id = $2`,
|
||||
commentID, postID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unpin comment: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrCommentNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes the comment. For a thread root this cascades to its replies
|
||||
// (and all comment_likes) via ON DELETE CASCADE. Authorization is the caller's
|
||||
// responsibility.
|
||||
func (r *CommentRepo) Delete(ctx context.Context, commentID string) error {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `DELETE FROM post_comments WHERE id = $1`, commentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete comment: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrCommentNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateReport files a report against a comment.
|
||||
func (r *CommentRepo) CreateReport(ctx context.Context, reporterID, commentID, category, reason string) error {
|
||||
if _, err := r.pg.Pool.Exec(ctx,
|
||||
`INSERT INTO comment_reports (reporter_id, comment_id, category, reason)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
reporterID, commentID, category, reason,
|
||||
); err != nil {
|
||||
return fmt.Errorf("create comment report: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// feedSeenTTL bounds a home-feed "session": after this idle window the
|
||||
// per-user seen-set expires and the feed starts fresh from the top.
|
||||
const feedSeenTTL = 30 * time.Minute
|
||||
|
||||
// FeedRepo tracks, per user, which post ids have already been served in the
|
||||
// current home-feed session (a Redis SET) so paginated requests never repeat
|
||||
// a post — and so the viewer-state-dependent feed ordering can't cause an
|
||||
// OFFSET to drift over already-served rows.
|
||||
//
|
||||
// Nil-safe: with no Redis configured every method degrades to a no-op /
|
||||
// empty, and the feed falls back to plain offset paging.
|
||||
type FeedRepo struct {
|
||||
redis *Redis
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewFeedRepo(redis *Redis, logger *zap.Logger) *FeedRepo {
|
||||
return &FeedRepo{redis: redis, logger: logger}
|
||||
}
|
||||
|
||||
// Enabled reports whether the Redis-backed seen-set is available.
|
||||
func (r *FeedRepo) Enabled() bool { return r != nil && r.redis != nil }
|
||||
|
||||
func (r *FeedRepo) key(userID string) string { return "feed:seen:" + userID }
|
||||
|
||||
// Seen returns the post ids already served to the user this session (empty
|
||||
// when Redis is unavailable or the session is fresh).
|
||||
func (r *FeedRepo) Seen(ctx context.Context, userID string) []string {
|
||||
if !r.Enabled() {
|
||||
return nil
|
||||
}
|
||||
ids, err := r.redis.Client.SMembers(ctx, r.key(userID)).Result()
|
||||
if err != nil {
|
||||
// Degrade rather than fail the feed — worst case is a possible repeat.
|
||||
r.logger.Warn("feed seen: read failed", zap.String("user", userID), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// MarkSeen records the ids just served and refreshes the session TTL.
|
||||
func (r *FeedRepo) MarkSeen(ctx context.Context, userID string, ids []string) {
|
||||
if !r.Enabled() || len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
members := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
members[i] = id
|
||||
}
|
||||
pipe := r.redis.Client.Pipeline()
|
||||
pipe.SAdd(ctx, r.key(userID), members...)
|
||||
pipe.Expire(ctx, r.key(userID), feedSeenTTL)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
r.logger.Warn("feed seen: write failed", zap.String("user", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears the user's seen-set — called when a fresh feed session begins
|
||||
// (the first page request) so reopening the feed serves from the top again.
|
||||
func (r *FeedRepo) Reset(ctx context.Context, userID string) {
|
||||
if !r.Enabled() {
|
||||
return
|
||||
}
|
||||
if err := r.redis.Client.Del(ctx, r.key(userID)).Err(); err != nil {
|
||||
r.logger.Warn("feed seen: reset failed", zap.String("user", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// FeedbackRepo wraps Postgres access for the feedback table.
|
||||
type FeedbackRepo struct{ pg *Postgres }
|
||||
|
||||
func NewFeedbackRepo(pg *Postgres) *FeedbackRepo { return &FeedbackRepo{pg: pg} }
|
||||
|
||||
// Create inserts a single feedback row. userID is a pointer so the
|
||||
// authed and anonymous paths share one method; contactEmail is also a
|
||||
// pointer because authed submissions don't carry one (we read the
|
||||
// submitter's email from the joined user row at notify time).
|
||||
func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
|
||||
f := &model.Feedback{}
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO feedback (user_id, category, message, contact_email)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, user_id, category, message, contact_email, created_at`,
|
||||
userID, category, message, contactEmail,
|
||||
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert feedback: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// FindByID returns one feedback row joined with the submitter's
|
||||
// handle + name + email if a user_id is present. Used after Create to
|
||||
// assemble the admin notification email with the proper author block,
|
||||
// and after MarkResolved to assemble the author thank-you email.
|
||||
func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) {
|
||||
f := &model.Feedback{}
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
|
||||
f.acknowledged_at, f.resolved_at,
|
||||
u.handle, u.name, u.email
|
||||
FROM feedback f
|
||||
LEFT JOIN users u ON u.id = f.user_id
|
||||
WHERE f.id = $1`, id,
|
||||
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||
&f.AcknowledgedAt, &f.ResolvedAt,
|
||||
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find feedback: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// MarkAcknowledged stamps acknowledged_at = now() on a not-yet-
|
||||
// acknowledged row. The `acknowledged_at IS NULL` guard makes this
|
||||
// idempotent and one-way: first call returns true (newly acknowledged →
|
||||
// fire the email), any later call — or an unknown id — returns false.
|
||||
func (r *FeedbackRepo) MarkAcknowledged(ctx context.Context, id string) (acked bool, err error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE feedback
|
||||
SET acknowledged_at = now()
|
||||
WHERE id = $1 AND acknowledged_at IS NULL`, id,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("mark feedback acknowledged: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// MarkResolved stamps resolved_at = now() on a still-open row. The
|
||||
// `resolved_at IS NULL` guard makes this idempotent and one-way: the
|
||||
// first call returns true (newly resolved), any subsequent call — or a
|
||||
// call on an unknown id — returns false so the caller knows NOT to fire
|
||||
// the thank-you email a second time.
|
||||
func (r *FeedbackRepo) MarkResolved(ctx context.Context, id string) (resolved bool, err error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE feedback
|
||||
SET resolved_at = now()
|
||||
WHERE id = $1 AND resolved_at IS NULL`, id,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("mark feedback resolved: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// Recent returns the most-recent N feedback rows joined with users
|
||||
// for display on /stats. NULL author columns mean anonymous (or the
|
||||
// original submitter's account was since deleted).
|
||||
func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 25
|
||||
}
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
|
||||
f.acknowledged_at, f.resolved_at,
|
||||
u.handle, u.name
|
||||
FROM feedback f
|
||||
LEFT JOIN users u ON u.id = f.user_id
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $1`, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query recent feedback: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.Feedback{}
|
||||
for rows.Next() {
|
||||
var f model.Feedback
|
||||
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||
&f.AcknowledgedAt, &f.ResolvedAt,
|
||||
&f.AuthorHandle, &f.AuthorName); err != nil {
|
||||
return nil, fmt.Errorf("scan feedback: %w", err)
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// likeDeltaPrefix namespaces the per-post pending-like counters in Redis. Each
|
||||
// like/unlike INCRBY/DECRBYs likes:delta:{postID}; the LikesWorker folds these
|
||||
// into posts.likes_count every 15 minutes (GETDEL + UPDATE). Reads add the
|
||||
// still-pending delta on top of likes_count so the displayed total is current.
|
||||
const likeDeltaPrefix = "likes:delta:"
|
||||
|
||||
// LikeRepo handles post-like membership (Postgres) and the write-behind count
|
||||
// delta (Redis). Membership is written synchronously so viewer state and the
|
||||
// "liked by" list stay consistent; only the hot counter is batched.
|
||||
//
|
||||
// `redis` is optional: when nil (Redis unavailable) likes still work, but the
|
||||
// count is applied to posts.likes_count directly on each like instead of being
|
||||
// batched.
|
||||
type LikeRepo struct {
|
||||
pg *Postgres
|
||||
redis *Redis
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewLikeRepo(pg *Postgres, redis *Redis, logger *zap.Logger) *LikeRepo {
|
||||
return &LikeRepo{pg: pg, redis: redis, logger: logger}
|
||||
}
|
||||
|
||||
func (r *LikeRepo) deltaKey(postID string) string { return likeDeltaPrefix + postID }
|
||||
|
||||
// Like records a membership row. Returns whether it was newly liked (false if
|
||||
// the user had already liked it — idempotent). On a real change it nudges the
|
||||
// pending count up.
|
||||
func (r *LikeRepo) Like(ctx context.Context, postID, userID string) (bool, error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx,
|
||||
`INSERT INTO post_likes (post_id, user_id) VALUES ($1, $2)
|
||||
ON CONFLICT (post_id, user_id) DO NOTHING`,
|
||||
postID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("insert like: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return false, nil // already liked
|
||||
}
|
||||
r.bumpDelta(ctx, postID, 1)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Unlike removes the membership row. Returns whether it actually changed.
|
||||
func (r *LikeRepo) Unlike(ctx context.Context, postID, userID string) (bool, error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx,
|
||||
`DELETE FROM post_likes WHERE post_id = $1 AND user_id = $2`,
|
||||
postID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("delete like: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return false, nil // wasn't liked
|
||||
}
|
||||
r.bumpDelta(ctx, postID, -1)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// bumpDelta adjusts the pending count. Prefers the Redis write-behind counter;
|
||||
// falls back to a direct DB update when Redis is unavailable so the count is
|
||||
// never lost.
|
||||
func (r *LikeRepo) bumpDelta(ctx context.Context, postID string, d int64) {
|
||||
if r.redis != nil {
|
||||
if err := r.redis.Client.IncrBy(ctx, r.deltaKey(postID), d).Err(); err == nil {
|
||||
return
|
||||
}
|
||||
r.logger.Warn("likes: redis incr failed, applying to DB", zap.String("post", postID))
|
||||
}
|
||||
if _, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE posts SET likes_count = GREATEST(0, likes_count + $2) WHERE id = $1`,
|
||||
postID, d,
|
||||
); err != nil {
|
||||
r.logger.Warn("likes: direct count update failed", zap.String("post", postID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// PostAuthor returns the user id that owns the post (for like notifications).
|
||||
func (r *LikeRepo) PostAuthor(ctx context.Context, postID string) (string, error) {
|
||||
var uid string
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT user_id FROM posts WHERE id = $1`, postID,
|
||||
).Scan(&uid); err != nil {
|
||||
return "", fmt.Errorf("post author: %w", err)
|
||||
}
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
// EffectiveCount returns likes_count + the pending Redis delta for one post.
|
||||
func (r *LikeRepo) EffectiveCount(ctx context.Context, postID string) (int, error) {
|
||||
var c int
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT likes_count FROM posts WHERE id = $1`, postID,
|
||||
).Scan(&c); err != nil {
|
||||
return 0, fmt.Errorf("read likes_count: %w", err)
|
||||
}
|
||||
if r.redis != nil {
|
||||
if d, err := r.redis.Client.Get(ctx, r.deltaKey(postID)).Int(); err == nil {
|
||||
c += d
|
||||
}
|
||||
}
|
||||
if c < 0 {
|
||||
c = 0
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Hydrate fills LikesCount (effective) and ViewerHasLiked on the given posts.
|
||||
// Used by every post read path. viewerID "" leaves ViewerHasLiked false.
|
||||
func (r *LikeRepo) Hydrate(ctx context.Context, viewerID string, posts []*model.Post) error {
|
||||
ids := make([]string, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if p != nil {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Denormalized counts from Postgres.
|
||||
counts := make(map[string]int, len(ids))
|
||||
rows, err := r.pg.Pool.Query(ctx,
|
||||
`SELECT id, likes_count FROM posts WHERE id = ANY($1)`, ids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hydrate like counts: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var c int
|
||||
if err := rows.Scan(&id, &c); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("scan like count: %w", err)
|
||||
}
|
||||
counts[id] = c
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pending deltas from Redis (best-effort).
|
||||
deltas := r.pendingDeltas(ctx, ids)
|
||||
|
||||
// Viewer membership.
|
||||
liked := make(map[string]bool)
|
||||
if viewerID != "" {
|
||||
lr, err := r.pg.Pool.Query(ctx,
|
||||
`SELECT post_id FROM post_likes WHERE user_id = $1 AND post_id = ANY($2)`,
|
||||
viewerID, ids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hydrate viewer likes: %w", err)
|
||||
}
|
||||
for lr.Next() {
|
||||
var pid string
|
||||
if err := lr.Scan(&pid); err != nil {
|
||||
lr.Close()
|
||||
return fmt.Errorf("scan viewer like: %w", err)
|
||||
}
|
||||
liked[pid] = true
|
||||
}
|
||||
lr.Close()
|
||||
if err := lr.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range posts {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
c := counts[p.ID] + deltas[p.ID]
|
||||
if c < 0 {
|
||||
c = 0
|
||||
}
|
||||
p.LikesCount = c
|
||||
p.ViewerHasLiked = liked[p.ID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pendingDeltas returns the Redis like-deltas for the given posts (absent →
|
||||
// zero). Best-effort: Redis errors yield an empty map.
|
||||
func (r *LikeRepo) pendingDeltas(ctx context.Context, ids []string) map[string]int {
|
||||
out := make(map[string]int)
|
||||
if r.redis == nil || len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
keys := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
keys[i] = r.deltaKey(id)
|
||||
}
|
||||
vals, err := r.redis.Client.MGet(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for i, v := range vals {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.Atoi(s); err == nil && n != 0 {
|
||||
out[ids[i]] = n
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListLikers returns one page of users who liked the post (newest first),
|
||||
// optionally filtered by a name/handle search, plus the total matching count.
|
||||
func (r *LikeRepo) ListLikers(ctx context.Context, postID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||
like := "%" + q + "%"
|
||||
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM post_likes l JOIN users u ON u.id = l.user_id
|
||||
WHERE l.post_id = $1
|
||||
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)`,
|
||||
postID, q, like,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count likers: %w", err)
|
||||
}
|
||||
if total == 0 {
|
||||
return []model.UserSummary{}, 0, nil
|
||||
}
|
||||
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT u.handle, u.name, u.avatar_url
|
||||
FROM post_likes l JOIN users u ON u.id = l.user_id
|
||||
WHERE l.post_id = $1
|
||||
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
|
||||
ORDER BY l.created_at DESC, u.handle ASC
|
||||
LIMIT $4 OFFSET $5`,
|
||||
postID, q, like, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list likers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]model.UserSummary, 0, limit)
|
||||
for rows.Next() {
|
||||
var s model.UserSummary
|
||||
if err := rows.Scan(&s.Handle, &s.Name, &s.AvatarURL); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan liker: %w", err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// FlushDeltas drains every pending Redis like-delta into posts.likes_count.
|
||||
// Called periodically by the LikesWorker. Returns how many posts were updated.
|
||||
// GETDEL makes each pop atomic; a failed UPDATE restores the delta so it's
|
||||
// retried next cycle.
|
||||
func (r *LikeRepo) FlushDeltas(ctx context.Context) (int, error) {
|
||||
if r.redis == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var cursor uint64
|
||||
applied := 0
|
||||
for {
|
||||
keys, next, err := r.redis.Client.Scan(ctx, cursor, likeDeltaPrefix+"*", 200).Result()
|
||||
if err != nil {
|
||||
return applied, fmt.Errorf("scan like deltas: %w", err)
|
||||
}
|
||||
for _, key := range keys {
|
||||
postID := strings.TrimPrefix(key, likeDeltaPrefix)
|
||||
v, err := r.redis.Client.GetDel(ctx, key).Int()
|
||||
if err != nil || v == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE posts SET likes_count = GREATEST(0, likes_count + $2) WHERE id = $1`,
|
||||
postID, v,
|
||||
); err != nil {
|
||||
// Restore so the delta isn't lost; retried next cycle.
|
||||
r.redis.Client.IncrBy(ctx, key, int64(v))
|
||||
r.logger.Warn("likes flush: update failed, delta restored",
|
||||
zap.String("post", postID), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
applied++
|
||||
}
|
||||
cursor = next
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// maxMentionsPerPost caps how many users a post can tag.
|
||||
const maxMentionsPerPost = 30
|
||||
|
||||
// MentionRepo handles post_mentions membership + mention suggestions.
|
||||
type MentionRepo struct{ pg *Postgres }
|
||||
|
||||
func NewMentionRepo(pg *Postgres) *MentionRepo { return &MentionRepo{pg: pg} }
|
||||
|
||||
// Suggest returns up to `limit` users to mention, prioritised: people the
|
||||
// viewer follows (rank 0), then people who follow the viewer (rank 1), then
|
||||
// everyone else (rank 2). Excludes the viewer. Optional name/handle search.
|
||||
// Offset paginates for the infinite-scroll popover.
|
||||
func (r *MentionRepo) Suggest(ctx context.Context, viewerID, q string, limit, offset int) ([]model.UserSummary, error) {
|
||||
like := "%" + q + "%"
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT u.id, u.handle, u.name, u.avatar_url,
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM follows f
|
||||
WHERE f.follower_id = $1 AND f.following_id = u.id
|
||||
AND f.status = 'accepted') THEN 0
|
||||
WHEN EXISTS (SELECT 1 FROM follows f
|
||||
WHERE f.follower_id = u.id AND f.following_id = $1
|
||||
AND f.status = 'accepted') THEN 1
|
||||
ELSE 2
|
||||
END AS rank
|
||||
FROM users u
|
||||
WHERE u.id <> $1
|
||||
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
|
||||
ORDER BY rank, u.name, u.id
|
||||
LIMIT $4 OFFSET $5`,
|
||||
viewerID, q, like, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest mentions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]model.UserSummary, 0, limit)
|
||||
for rows.Next() {
|
||||
var s model.UserSummary
|
||||
var rank int
|
||||
if err := rows.Scan(&s.ID, &s.Handle, &s.Name, &s.AvatarURL, &rank); err != nil {
|
||||
return nil, fmt.Errorf("scan suggestion: %w", err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AddMany tags the given users on a post (idempotent, capped). Used on create.
|
||||
func (r *MentionRepo) AddMany(ctx context.Context, postID string, userIDs []string) error {
|
||||
if len(userIDs) > maxMentionsPerPost {
|
||||
userIDs = userIDs[:maxMentionsPerPost]
|
||||
}
|
||||
for _, uid := range userIDs {
|
||||
if _, err := r.pg.Pool.Exec(ctx,
|
||||
`INSERT INTO post_mentions (post_id, user_id) VALUES ($1, $2)
|
||||
ON CONFLICT (post_id, user_id) DO NOTHING`,
|
||||
postID, uid,
|
||||
); err != nil {
|
||||
return fmt.Errorf("add mention: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add tags a single user (idempotent), enforcing the per-post cap.
|
||||
func (r *MentionRepo) Add(ctx context.Context, postID, userID string) error {
|
||||
var n int
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM post_mentions WHERE post_id = $1`, postID,
|
||||
).Scan(&n); err != nil {
|
||||
return fmt.Errorf("count mentions: %w", err)
|
||||
}
|
||||
if n >= maxMentionsPerPost {
|
||||
return fmt.Errorf("a post can mention at most %d people", maxMentionsPerPost)
|
||||
}
|
||||
if _, err := r.pg.Pool.Exec(ctx,
|
||||
`INSERT INTO post_mentions (post_id, user_id) VALUES ($1, $2)
|
||||
ON CONFLICT (post_id, user_id) DO NOTHING`,
|
||||
postID, userID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("add mention: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove untags a user from a post.
|
||||
func (r *MentionRepo) Remove(ctx context.Context, postID, userID string) error {
|
||||
if _, err := r.pg.Pool.Exec(ctx,
|
||||
`DELETE FROM post_mentions WHERE post_id = $1 AND user_id = $2`,
|
||||
postID, userID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("remove mention: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListForPost returns the users tagged on a post (insertion order).
|
||||
func (r *MentionRepo) ListForPost(ctx context.Context, postID string) ([]model.UserSummary, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT u.id, u.handle, u.name, u.avatar_url
|
||||
FROM post_mentions m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.post_id = $1
|
||||
ORDER BY m.created_at ASC`, postID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list mentions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.UserSummary{}
|
||||
for rows.Next() {
|
||||
var s model.UserSummary
|
||||
if err := rows.Scan(&s.ID, &s.Handle, &s.Name, &s.AvatarURL); err != nil {
|
||||
return nil, fmt.Errorf("scan mention: %w", err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// HydrateCounts fills MentionsCount on the given posts (one grouped query).
|
||||
func (r *MentionRepo) HydrateCounts(ctx context.Context, posts []*model.Post) error {
|
||||
ids := make([]string, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if p != nil {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := r.pg.Pool.Query(ctx,
|
||||
`SELECT post_id, count(*) FROM post_mentions WHERE post_id = ANY($1) GROUP BY post_id`, ids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hydrate mention counts: %w", err)
|
||||
}
|
||||
counts := make(map[string]int, len(ids))
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var c int
|
||||
if err := rows.Scan(&id, &c); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("scan mention count: %w", err)
|
||||
}
|
||||
counts[id] = c
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range posts {
|
||||
if p != nil {
|
||||
p.MentionsCount = counts[p.ID]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Notification delivery channels (notification_prefs.channel values).
|
||||
const (
|
||||
ChannelInApp = "in_app"
|
||||
ChannelEmail = "email"
|
||||
ChannelPush = "push"
|
||||
)
|
||||
|
||||
// PrefKey is the lookup key for a (kind, channel) cell in a DisabledSet.
|
||||
func PrefKey(kind, channel string) string { return kind + "/" + channel }
|
||||
|
||||
// NotificationPrefsRepo stores per-user notification opt-outs: one row per
|
||||
// (kind, channel) the user has turned OFF. Absence of a row means enabled,
|
||||
// so the table only ever holds overrides and new kinds/channels default on.
|
||||
type NotificationPrefsRepo struct{ pg *Postgres }
|
||||
|
||||
func NewNotificationPrefsRepo(pg *Postgres) *NotificationPrefsRepo {
|
||||
return &NotificationPrefsRepo{pg: pg}
|
||||
}
|
||||
|
||||
// DisabledSet returns the user's opt-outs keyed by PrefKey(kind, channel).
|
||||
func (r *NotificationPrefsRepo) DisabledSet(ctx context.Context, userID string) (map[string]struct{}, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT kind, channel FROM notification_prefs
|
||||
WHERE user_id = $1 AND enabled = FALSE`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list notification prefs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var kind, channel string
|
||||
if err := rows.Scan(&kind, &channel); err != nil {
|
||||
return nil, fmt.Errorf("scan notification pref: %w", err)
|
||||
}
|
||||
out[PrefKey(kind, channel)] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Disable records opt-out rows for every kind × channel pair.
|
||||
func (r *NotificationPrefsRepo) Disable(ctx context.Context, userID string, kinds, channels []string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
INSERT INTO notification_prefs (user_id, kind, channel, enabled)
|
||||
SELECT $1, k, c, FALSE
|
||||
FROM unnest($2::text[]) AS k, unnest($3::text[]) AS c
|
||||
ON CONFLICT (user_id, kind, channel)
|
||||
DO UPDATE SET enabled = FALSE, updated_at = now()`,
|
||||
userID, kinds, channels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("disable notification prefs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enable removes opt-out rows (absence = enabled) for every kind × channel
|
||||
// pair.
|
||||
func (r *NotificationPrefsRepo) Enable(ctx context.Context, userID string, kinds, channels []string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
DELETE FROM notification_prefs
|
||||
WHERE user_id = $1 AND kind = ANY($2::text[]) AND channel = ANY($3::text[])`,
|
||||
userID, kinds, channels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enable notification prefs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncLegacyColumns recomputes users.like_emails_enabled and
|
||||
// users.sadhna_reminders_enabled from the prefs table, so an old frontend
|
||||
// (or a rollback) reading the legacy columns stays correct. Call after any
|
||||
// prefs write; drop together with the columns once fully rolled out.
|
||||
func (r *NotificationPrefsRepo) SyncLegacyColumns(ctx context.Context, userID string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE users SET
|
||||
like_emails_enabled = NOT EXISTS (
|
||||
SELECT 1 FROM notification_prefs np
|
||||
WHERE np.user_id = users.id
|
||||
AND np.kind = 'post_like' AND np.channel = 'email'
|
||||
AND np.enabled = FALSE),
|
||||
-- The legacy reminder toggle was all-or-nothing: it reads as OFF
|
||||
-- only when every reminder kind × channel cell is opted out.
|
||||
sadhna_reminders_enabled = (
|
||||
SELECT count(*) FROM notification_prefs np
|
||||
WHERE np.user_id = users.id
|
||||
AND np.kind IN ('chant_reminder', 'quote_reminder', 'chant_incomplete_reminder')
|
||||
AND np.enabled = FALSE) < 9
|
||||
WHERE id = $1`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sync legacy notification prefs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// ErrOAuthAccountNotFound mirrors the other repo sentinels.
|
||||
var ErrOAuthAccountNotFound = errors.New("oauth account not found")
|
||||
|
||||
// OAuthAccountRepo manages provider linkage rows.
|
||||
type OAuthAccountRepo struct {
|
||||
pg *Postgres
|
||||
}
|
||||
|
||||
func NewOAuthAccountRepo(pg *Postgres) *OAuthAccountRepo {
|
||||
return &OAuthAccountRepo{pg: pg}
|
||||
}
|
||||
|
||||
// FindByProvider returns the linkage row for the given (provider, provider_user_id),
|
||||
// or ErrOAuthAccountNotFound. Use this first on every callback — a hit means
|
||||
// this provider identity has been seen before and we just log the user in.
|
||||
func (r *OAuthAccountRepo) FindByProvider(ctx context.Context, provider, providerUserID string) (*model.OAuthAccount, error) {
|
||||
const q = `
|
||||
SELECT id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at
|
||||
FROM oauth_accounts
|
||||
WHERE provider = $1 AND provider_user_id = $2
|
||||
LIMIT 1`
|
||||
a := &model.OAuthAccount{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, provider, providerUserID).Scan(
|
||||
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
|
||||
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrOAuthAccountNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("find oauth account: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ListProvidersForUser returns the distinct provider names linked to this
|
||||
// user, ordered alphabetically. Used by the settings UI to mark each
|
||||
// social-connect row as "Connected" vs "Connect".
|
||||
func (r *OAuthAccountRepo) ListProvidersForUser(ctx context.Context, userID string) ([]string, error) {
|
||||
const q = `
|
||||
SELECT DISTINCT provider
|
||||
FROM oauth_accounts
|
||||
WHERE user_id = $1
|
||||
ORDER BY provider`
|
||||
rows, err := r.pg.Pool.Query(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list oauth providers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
return nil, fmt.Errorf("scan oauth provider: %w", err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Link inserts a new oauth_accounts row tying a provider identity to an
|
||||
// existing user. Idempotent: a second call for the same (provider, sub)
|
||||
// returns the existing row instead of erroring on the unique constraint.
|
||||
func (r *OAuthAccountRepo) Link(ctx context.Context, userID, provider, providerUserID string, email, name, avatarURL *string) (*model.OAuthAccount, error) {
|
||||
const q = `
|
||||
INSERT INTO oauth_accounts (user_id, provider, provider_user_id, email, name, avatar_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (provider, provider_user_id) DO UPDATE
|
||||
SET email = EXCLUDED.email,
|
||||
name = EXCLUDED.name,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
updated_at = now()
|
||||
RETURNING id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at`
|
||||
a := &model.OAuthAccount{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, userID, provider, providerUserID, email, name, avatarURL).Scan(
|
||||
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
|
||||
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("link oauth account: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
@@ -13,10 +14,55 @@ import (
|
||||
var ErrPostNotFound = errors.New("post not found")
|
||||
|
||||
// PostRepo wraps Postgres access for posts + post_images.
|
||||
//
|
||||
// Reshare model: a reshare is a real `posts` row owned by the resharer
|
||||
// with `reshared_from_post_id` pointing at the canonical original. Likes
|
||||
// and comments (future) will hang off post.id, so they diverge between
|
||||
// original and reshare for free. Backgrounds are per-row in post_images,
|
||||
// same story. The only thing the original and its reshares share is the
|
||||
// caption / source_url / source_title at the moment of reshare.
|
||||
type PostRepo struct{ pg *Postgres }
|
||||
|
||||
func NewPostRepo(pg *Postgres) *PostRepo { return &PostRepo{pg: pg} }
|
||||
|
||||
// postSelectColumns is the canonical column list used by every reader so
|
||||
// every Post payload carries the same shape — including reshare
|
||||
// attribution. The LEFT JOINs to `original_post` + `original_user` are
|
||||
// always present; for canonical (non-reshare) rows they just yield NULL.
|
||||
const postSelectColumns = `
|
||||
p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||
p.created_at, p.updated_at,
|
||||
u.name, u.handle, u.avatar_url,
|
||||
p.reshared_from_post_id,
|
||||
ou.name AS original_author_name,
|
||||
ou.handle AS original_author_handle,
|
||||
ou.avatar_url AS original_author_avatar,
|
||||
p.is_hidden
|
||||
`
|
||||
|
||||
const postSelectFrom = `
|
||||
FROM posts p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
LEFT JOIN posts op ON op.id = p.reshared_from_post_id
|
||||
LEFT JOIN users ou ON ou.id = op.user_id
|
||||
`
|
||||
|
||||
// scanPost decodes one row produced by `postSelectColumns` into the
|
||||
// destination Post. Single source of truth for the column → struct
|
||||
// mapping, used by every reader.
|
||||
func scanPost(row interface {
|
||||
Scan(dest ...any) error
|
||||
}, p *model.Post) error {
|
||||
return row.Scan(
|
||||
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
||||
&p.CreatedAt, &p.UpdatedAt,
|
||||
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||
&p.ResharedFromPostID,
|
||||
&p.OriginalAuthorName, &p.OriginalAuthorHandle, &p.OriginalAuthorAvatar,
|
||||
&p.IsHidden,
|
||||
)
|
||||
}
|
||||
|
||||
// Create inserts a post + its images in a single transaction so we never
|
||||
// end up with orphan images.
|
||||
func (r *PostRepo) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
|
||||
@@ -57,12 +103,9 @@ func (r *PostRepo) Create(ctx context.Context, userID, caption, sourceURL, sourc
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ReplaceImages atomically swaps a post's image set. Ownership is
|
||||
// enforced inside the tx by locking the post row and checking user_id —
|
||||
// non-owners get ErrPostNotFound (we don't distinguish "not yours" from
|
||||
// "doesn't exist" to non-owners). Existing rows are wiped, the new URLs
|
||||
// are inserted with sequential positions, and the post's `updated_at`
|
||||
// is touched so list views can sort by recency if needed later.
|
||||
// ReplaceImages atomically swaps a post's image set. Owner-only — every
|
||||
// reshare is its own posts row now, so the resharer is the owner of
|
||||
// their reshare and edits it just like any other post they made.
|
||||
func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, imageURLs []string) error {
|
||||
tx, err := r.pg.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -98,27 +141,18 @@ func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, ima
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// FindByID returns one post with its images and author info.
|
||||
// FindByID returns one post with its images and author info (plus original
|
||||
// author info if this row is a reshare).
|
||||
func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
|
||||
const q = `
|
||||
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
|
||||
FROM posts p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE p.id = $1`
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1 AND p.deleted_at IS NULL`
|
||||
p := &model.Post{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, id).Scan(
|
||||
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
||||
&p.CreatedAt, &p.UpdatedAt,
|
||||
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||
)
|
||||
if err != nil {
|
||||
if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("find post: %w", err)
|
||||
}
|
||||
imgs, err := r.imagesFor(ctx, []string{p.ID})
|
||||
imgs, err := r.ImagesFor(ctx, []string{p.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -126,46 +160,195 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ListByHandle returns the public feed for a user, newest-first, paginated.
|
||||
// ListByHandle returns the profile feed for a user. Unknown handle →
|
||||
// empty page (matches pre-existing behaviour).
|
||||
func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
|
||||
var userID string
|
||||
if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return []model.Post{}, 0, nil
|
||||
}
|
||||
return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
|
||||
}
|
||||
return r.ListByUser(ctx, userID, limit, offset)
|
||||
}
|
||||
|
||||
// ListMentioningByHandle returns posts that tag the given user (handle),
|
||||
// newest first — the "Mentions" profile tab. Same shape as the profile feed.
|
||||
func (r *PostRepo) ListMentioningByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
|
||||
var userID string
|
||||
if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return []model.Post{}, 0, nil
|
||||
}
|
||||
return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
|
||||
}
|
||||
|
||||
// Hidden posts are dropped from the mentions timeline too (the mentioned
|
||||
// user is not the author, so there is no owner exception here).
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM posts p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE u.handle = $1`, handle,
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM posts p JOIN post_mentions pm ON pm.post_id = p.id WHERE pm.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL`,
|
||||
userID,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count posts: %w", err)
|
||||
return nil, 0, fmt.Errorf("count mentioning posts: %w", err)
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
|
||||
FROM posts p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE u.handle = $1
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2 OFFSET $3`
|
||||
return r.collectPosts(ctx, total, q, handle, limit, offset)
|
||||
}
|
||||
|
||||
// ListByUser returns the user's own posts (private listing — same shape).
|
||||
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count posts: %w", err)
|
||||
}
|
||||
const q = `
|
||||
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
|
||||
FROM posts p
|
||||
JOIN users u ON u.id = p.user_id
|
||||
WHERE p.user_id = $1
|
||||
ORDER BY p.created_at DESC
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
JOIN post_mentions pm ON pm.post_id = p.id
|
||||
WHERE pm.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL
|
||||
ORDER BY p.created_at DESC, p.id
|
||||
LIMIT $2 OFFSET $3`
|
||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||
}
|
||||
|
||||
// Delete removes a post only if it belongs to the caller.
|
||||
// ListByUser returns the user's profile feed — every post they own,
|
||||
// authored or reshared. Reshares are interleaved naturally because
|
||||
// they're stored as real rows; sort by created_at gets the right order
|
||||
// without any UNION gymnastics.
|
||||
//
|
||||
// Hidden posts are excluded from this timeline for everyone, including the
|
||||
// owner — they live in the owner's separate "Hidden" list (ListHiddenByUser).
|
||||
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||
total, err := r.CountByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL
|
||||
ORDER BY p.created_at DESC, p.id
|
||||
LIMIT $2 OFFSET $3`
|
||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||
}
|
||||
|
||||
// ListHiddenByUser returns the owner's hidden posts, newest first — the
|
||||
// /u/me "Hidden" tab. Owner-only (the caller enforces auth + scoping).
|
||||
func (r *PostRepo) ListHiddenByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||
total, err := r.CountHiddenByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id = $1 AND p.is_hidden = TRUE AND p.deleted_at IS NULL
|
||||
ORDER BY p.created_at DESC, p.id
|
||||
LIMIT $2 OFFSET $3`
|
||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||
}
|
||||
|
||||
// FeedForViewer returns one page of the global home feed for `viewerID`,
|
||||
// newest-first within a two-tier priority:
|
||||
//
|
||||
// tier 0 — posts by people the viewer follows that they haven't liked yet
|
||||
// tier 1 — everything else (followed-and-liked, followers', strangers')
|
||||
//
|
||||
// The viewer's own posts, anything involving a block (either direction), and
|
||||
// any id in `exclude` (the already-served set) are filtered out. The tier is
|
||||
// computed in ORDER BY via correlated EXISTS, so no extra columns leak into
|
||||
// scanPost. No total is computed (infinite scroll infers "more" from a full
|
||||
// page).
|
||||
//
|
||||
// With a non-empty `exclude` the caller drives pagination purely by exclusion
|
||||
// (pass offset 0): since the tier depends on viewer state — liking a post
|
||||
// moves it between tiers — an OFFSET would drift and repeat/skip rows, so the
|
||||
// seen-set is the correct cursor. Without Redis, `exclude` is empty and OFFSET
|
||||
// paginates (the frontend de-dups the rare drift). At larger scale this moves
|
||||
// to a precomputed fan-out / keyset cursor.
|
||||
func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, offset int, exclude []string) ([]model.Post, error) {
|
||||
// nil would encode as SQL NULL, and `id <> ALL(NULL)` is NULL (excludes
|
||||
// every row); a non-nil empty array means "exclude nothing".
|
||||
if exclude == nil {
|
||||
exclude = []string{}
|
||||
}
|
||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id <> $1
|
||||
AND p.is_hidden = FALSE AND p.deleted_at IS NULL
|
||||
AND p.id <> ALL($4)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_blocks b
|
||||
WHERE (b.blocker_id = $1 AND b.blocked_id = p.user_id)
|
||||
OR (b.blocker_id = p.user_id AND b.blocked_id = $1)
|
||||
)
|
||||
ORDER BY
|
||||
(CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM follows f
|
||||
WHERE f.follower_id = $1 AND f.following_id = p.user_id
|
||||
AND f.status = 'accepted'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM post_likes pl
|
||||
WHERE pl.post_id = p.id AND pl.user_id = $1
|
||||
)
|
||||
THEN 0 ELSE 1
|
||||
END) ASC,
|
||||
p.created_at DESC, p.id DESC
|
||||
LIMIT $2 OFFSET $3`
|
||||
posts, _, err := r.collectPosts(ctx, 0, q, viewerID, limit, offset, exclude)
|
||||
return posts, err
|
||||
}
|
||||
|
||||
// HydrateViewerRelationships fills ViewerFollowsAuthor / AuthorFollowsViewer
|
||||
// across a page of feed posts in two batched queries (one per direction).
|
||||
// No-op for anonymous viewers.
|
||||
func (r *PostRepo) HydrateViewerRelationships(ctx context.Context, viewerID string, posts []model.Post) error {
|
||||
if viewerID == "" || len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
authorIDs := make([]string, 0, len(posts))
|
||||
for i := range posts {
|
||||
id := posts[i].UserID
|
||||
if id != "" && id != viewerID && !seen[id] {
|
||||
seen[id] = true
|
||||
authorIDs = append(authorIDs, id)
|
||||
}
|
||||
}
|
||||
if len(authorIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
following, err := r.followEdges(ctx,
|
||||
`SELECT following_id FROM follows
|
||||
WHERE follower_id = $1 AND status = 'accepted' AND following_id = ANY($2)`,
|
||||
viewerID, authorIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
followers, err := r.followEdges(ctx,
|
||||
`SELECT follower_id FROM follows
|
||||
WHERE following_id = $1 AND status = 'accepted' AND follower_id = ANY($2)`,
|
||||
viewerID, authorIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range posts {
|
||||
posts[i].ViewerFollowsAuthor = following[posts[i].UserID]
|
||||
posts[i].AuthorFollowsViewer = followers[posts[i].UserID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// followEdges runs a one-column follow query and collects the ids into a set.
|
||||
func (r *PostRepo) followEdges(ctx context.Context, query, viewerID string, ids []string) (map[string]bool, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, query, viewerID, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hydrate follow edges: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("scan follow edge: %w", err)
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Delete removes a post only if it belongs to the caller. Cascades:
|
||||
// - the post's images (post_images.post_id FK)
|
||||
// - any reshares of this post made by other users (posts.reshared_from_post_id FK)
|
||||
// - those reshares' images (post_images.post_id FK on each reshare)
|
||||
func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
|
||||
tag, err := r.pg.Pool.Exec(ctx,
|
||||
`DELETE FROM posts WHERE id = $1 AND user_id = $2`, postID, userID)
|
||||
@@ -178,17 +361,260 @@ func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByUser is used by services that need to know how many posts a user
|
||||
// has — currently the public-profile endpoint only.
|
||||
// SetHidden flips a post's hidden flag. Owner-only: the WHERE clause scopes
|
||||
// to user_id, so a non-owner (or unknown id) affects no rows → ErrPostNotFound.
|
||||
func (r *PostRepo) SetHidden(ctx context.Context, postID, userID string, hidden bool) error {
|
||||
tag, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE posts SET is_hidden = $3, updated_at = now() WHERE id = $1 AND user_id = $2`,
|
||||
postID, userID, hidden)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set post hidden: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByUser returns the user's total post count (authored + reshared).
|
||||
// Now just a single COUNT against posts.user_id.
|
||||
// CountByUser counts the user's visible (non-hidden) posts — matches the
|
||||
// timeline shown on their profile.
|
||||
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID).Scan(&n)
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = FALSE AND deleted_at IS NULL`, userID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count user posts: %w", err)
|
||||
return 0, fmt.Errorf("count posts: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CountHiddenByUser counts the owner's hidden posts (drives the "Hidden" tab's
|
||||
// visibility + badge).
|
||||
func (r *PostRepo) CountHiddenByUser(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = TRUE AND deleted_at IS NULL`, userID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count hidden posts: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// PostedDates returns the set of UTC calendar days, on or after `since`,
|
||||
// on which the user created at least one post. Used by the sādhana streak
|
||||
// to detect the "posted a quote today" discipline (and to score past days
|
||||
// of the streak) without loading whole posts. Keys are "YYYY-MM-DD".
|
||||
func (r *PostRepo) PostedDates(ctx context.Context, userID string, since time.Time) (map[string]bool, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT DISTINCT (created_at AT TIME ZONE 'UTC')::date AS d
|
||||
FROM posts
|
||||
WHERE user_id = $1 AND created_at >= $2`,
|
||||
userID, since,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("posted dates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var d time.Time
|
||||
if err := rows.Scan(&d); err != nil {
|
||||
return nil, fmt.Errorf("scan posted date: %w", err)
|
||||
}
|
||||
out[d.Format("2006-01-02")] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PostCountsByDate returns, for each calendar day on or after `since`, how
|
||||
// many posts the user created that day — bucketed in the given IANA timezone
|
||||
// (`tz`) so the day boundary matches the user's local midnight. Keys are
|
||||
// "YYYY-MM-DD"; days with no posts are absent (so a missing key reads as
|
||||
// zero). The sādhana page uses this for the "posted a quote today" discipline
|
||||
// and to annotate each day's fold with the number of quotes shared.
|
||||
func (r *PostRepo) PostCountsByDate(ctx context.Context, userID string, since time.Time, tz string) (map[string]int, error) {
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT (created_at AT TIME ZONE $3::text)::date AS d, count(*) AS n
|
||||
FROM posts
|
||||
WHERE user_id = $1 AND created_at >= $2
|
||||
GROUP BY d`,
|
||||
userID, since, tz,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("post counts by date: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var (
|
||||
d time.Time
|
||||
n int
|
||||
)
|
||||
if err := rows.Scan(&d, &n); err != nil {
|
||||
return nil, fmt.Errorf("scan post count: %w", err)
|
||||
}
|
||||
out[d.Format("2006-01-02")] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Reshare creates a new posts row owned by `resharerID` that points at
|
||||
// the canonical original via `reshared_from_post_id`. Copies the
|
||||
// original's caption / source_url / source_title and its current images
|
||||
// so the resharer's view renders identically at the moment of reshare;
|
||||
// after that the two diverge — edits to either side never touch the
|
||||
// other.
|
||||
//
|
||||
// If the post being reshared is itself a reshare we point at *its*
|
||||
// canonical (transitive ancestor), so every reshare always references
|
||||
// the true original.
|
||||
//
|
||||
// Idempotent: a user already resharing a canonical post returns the
|
||||
// existing reshare row instead of creating a second one.
|
||||
//
|
||||
// Service-layer rule (caller can't reshare their own canonical) is
|
||||
// enforced above this method.
|
||||
func (r *PostRepo) Reshare(ctx context.Context, originalPostID, resharerID string) (*model.Post, error) {
|
||||
tx, err := r.pg.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
// Load the post being reshared. Walk one level back to canonical
|
||||
// in case the caller is resharing someone else's reshare.
|
||||
var (
|
||||
canonicalID string
|
||||
caption string
|
||||
sourceURL string
|
||||
sourceTitle string
|
||||
canonicalUser string
|
||||
)
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE(p.reshared_from_post_id, p.id),
|
||||
p.caption, p.source_url, COALESCE(p.source_title, ''),
|
||||
COALESCE(op.user_id, p.user_id)
|
||||
FROM posts p
|
||||
LEFT JOIN posts op ON op.id = p.reshared_from_post_id
|
||||
WHERE p.id = $1`, originalPostID,
|
||||
).Scan(&canonicalID, &caption, &sourceURL, &sourceTitle, &canonicalUser); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("load canonical: %w", err)
|
||||
}
|
||||
if canonicalUser == resharerID {
|
||||
return nil, errors.New("cannot reshare your own post")
|
||||
}
|
||||
|
||||
// Idempotency: existing reshare by this user of this canonical?
|
||||
var existingID string
|
||||
switch err := tx.QueryRow(ctx, `
|
||||
SELECT id FROM posts
|
||||
WHERE user_id = $1 AND reshared_from_post_id = $2
|
||||
LIMIT 1`, resharerID, canonicalID,
|
||||
).Scan(&existingID); {
|
||||
case err == nil:
|
||||
// Already exists — commit the no-op tx and return the existing row.
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return r.FindByID(ctx, existingID)
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
// Fresh reshare path below.
|
||||
default:
|
||||
return nil, fmt.Errorf("check existing reshare: %w", err)
|
||||
}
|
||||
|
||||
var newID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO posts (user_id, caption, source_url, source_title, reshared_from_post_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id`,
|
||||
resharerID, caption, sourceURL, sourceTitle, canonicalID,
|
||||
).Scan(&newID); err != nil {
|
||||
return nil, fmt.Errorf("insert reshare post: %w", err)
|
||||
}
|
||||
|
||||
// Copy the canonical's current images as the resharer's starting set.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO post_images (post_id, url, position)
|
||||
SELECT $1, url, position
|
||||
FROM post_images
|
||||
WHERE post_id = $2`, newID, canonicalID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("copy images: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return r.FindByID(ctx, newID)
|
||||
}
|
||||
|
||||
// Unreshare removes the caller's reshare of a canonical post. The
|
||||
// caller passes the canonical post id (what the UI knows); we look up
|
||||
// the caller's reshare-row and delete it. Idempotent — no reshare to
|
||||
// delete is a no-op.
|
||||
func (r *PostRepo) Unreshare(ctx context.Context, canonicalPostID, resharerID string) error {
|
||||
if _, err := r.pg.Pool.Exec(ctx, `
|
||||
DELETE FROM posts
|
||||
WHERE user_id = $1 AND reshared_from_post_id = $2`,
|
||||
resharerID, canonicalPostID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("unreshare: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReshareStatesFor returns the set of post IDs from the supplied list
|
||||
// that the viewer has reshared. Used by feed renderers to flip the
|
||||
// post-card "Reshare ↔ Send" toggle.
|
||||
//
|
||||
// `postIDs` may carry either canonical posts or reshare rows; the query
|
||||
// resolves each id to its canonical first, then asks "does the viewer
|
||||
// have a row whose reshared_from_post_id equals that canonical?". The
|
||||
// returned map keys are the supplied ids — callers don't need to know
|
||||
// the canonical relationship.
|
||||
func (r *PostRepo) ReshareStatesFor(ctx context.Context, viewerID string, postIDs []string) (map[string]bool, error) {
|
||||
out := make(map[string]bool, len(postIDs))
|
||||
if viewerID == "" || len(postIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT p.id
|
||||
FROM posts p
|
||||
WHERE p.id = ANY($2)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM posts r
|
||||
WHERE r.user_id = $1
|
||||
AND r.reshared_from_post_id = COALESCE(p.reshared_from_post_id, p.id)
|
||||
)`, viewerID, postIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query reshare states: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("scan reshare state: %w", err)
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- internal helpers ---
|
||||
|
||||
func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, args ...any) ([]model.Post, int, error) {
|
||||
@@ -202,11 +628,7 @@ func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, ar
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var p model.Post
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
||||
&p.CreatedAt, &p.UpdatedAt,
|
||||
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||
); err != nil {
|
||||
if err := scanPost(rows, &p); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan post: %w", err)
|
||||
}
|
||||
posts = append(posts, p)
|
||||
@@ -217,7 +639,7 @@ func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, ar
|
||||
}
|
||||
|
||||
if len(ids) > 0 {
|
||||
imgs, err := r.imagesFor(ctx, ids)
|
||||
imgs, err := r.ImagesFor(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -228,9 +650,12 @@ func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, ar
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// imagesFor returns a map post_id → [images...] for a batch of post IDs in
|
||||
// a single query so we don't N+1 the feed endpoint.
|
||||
func (r *PostRepo) imagesFor(ctx context.Context, postIDs []string) (map[string][]model.PostImage, error) {
|
||||
// ImagesFor returns a map post_id → [images...] for a batch of post IDs in
|
||||
// a single query so we don't N+1 the feed endpoint. Exported because the
|
||||
// CollectionService needs to hydrate images onto posts that come back
|
||||
// from CollectionRepo.PostsIn (which only joins posts/users — not
|
||||
// post_images — to keep that query focused).
|
||||
func (r *PostRepo) ImagesFor(ctx context.Context, postIDs []string) (map[string][]model.PostImage, error) {
|
||||
out := make(map[string][]model.PostImage, len(postIDs))
|
||||
if len(postIDs) == 0 {
|
||||
return out, nil
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// PushRepo stores Web Push subscriptions (one per browser/device endpoint).
|
||||
type PushRepo struct{ pg *Postgres }
|
||||
|
||||
func NewPushRepo(pg *Postgres) *PushRepo { return &PushRepo{pg: pg} }
|
||||
|
||||
// Upsert records (or refreshes) a subscription. The endpoint is unique, so a
|
||||
// browser re-subscribing — possibly under a different user — rebinds cleanly.
|
||||
func (r *PushRepo) Upsert(ctx context.Context, userID, endpoint, p256dh, auth, userAgent string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (endpoint) DO UPDATE
|
||||
SET user_id = EXCLUDED.user_id,
|
||||
p256dh = EXCLUDED.p256dh,
|
||||
auth = EXCLUDED.auth,
|
||||
user_agent = EXCLUDED.user_agent,
|
||||
last_used_at = now()`,
|
||||
userID, endpoint, p256dh, auth, userAgent,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert push subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByUser returns the user's active subscriptions.
|
||||
func (r *PushRepo) ListByUser(ctx context.Context, userID string) ([]model.PushSubscription, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT id, user_id, endpoint, p256dh, auth
|
||||
FROM push_subscriptions WHERE user_id = $1`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list push subscriptions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []model.PushSubscription
|
||||
for rows.Next() {
|
||||
var s model.PushSubscription
|
||||
if err := rows.Scan(&s.ID, &s.UserID, &s.Endpoint, &s.P256dh, &s.Auth); err != nil {
|
||||
return nil, fmt.Errorf("scan push subscription: %w", err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PruneToMostRecent keeps only the `keep` most-recently-used subscriptions for
|
||||
// a user, deleting older ones — a soft per-user device cap.
|
||||
func (r *PushRepo) PruneToMostRecent(ctx context.Context, userID string, keep int) error {
|
||||
if keep < 1 {
|
||||
keep = 1
|
||||
}
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
DELETE FROM push_subscriptions
|
||||
WHERE user_id = $1 AND id NOT IN (
|
||||
SELECT id FROM push_subscriptions
|
||||
WHERE user_id = $1
|
||||
ORDER BY last_used_at DESC NULLS LAST
|
||||
LIMIT $2
|
||||
)`,
|
||||
userID, keep,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prune push subscriptions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByEndpoint removes a subscription (when the push service reports it
|
||||
// gone). Server-internal — user-initiated unsubscribes must go through
|
||||
// DeleteByEndpointForUser so one user can't remove another's subscription.
|
||||
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
|
||||
}
|
||||
|
||||
// DeleteByEndpointForUser removes a subscription only if it belongs to the
|
||||
// given user — the ownership-checked variant for the unsubscribe endpoint.
|
||||
func (r *PushRepo) DeleteByEndpointForUser(ctx context.Context, userID, endpoint string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx,
|
||||
`DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2`,
|
||||
endpoint, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete push subscription: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -92,6 +93,27 @@ func (r *R2) PresignPut(ctx context.Context, key, contentType string, expiry tim
|
||||
return req.URL, fmt.Sprintf("%s/%s", r.publicBase, key), nil
|
||||
}
|
||||
|
||||
// PutObject uploads bytes to R2 server-side (used by the one-off rewards
|
||||
// import tool, where the BFF itself holds the file). Returns the public URL.
|
||||
func (r *R2) PutObject(ctx context.Context, key, contentType string, body []byte) (string, error) {
|
||||
if r == nil {
|
||||
return "", errors.New("R2 not configured")
|
||||
}
|
||||
if key == "" {
|
||||
return "", errors.New("empty key")
|
||||
}
|
||||
_, err := r.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(r.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(body),
|
||||
ContentType: aws.String(contentType),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("put object %q: %w", key, err)
|
||||
}
|
||||
return r.PublicURL(key), nil
|
||||
}
|
||||
|
||||
// PublicURL builds the public URL for an arbitrary key. Useful when reading
|
||||
// existing user.avatar_url values back.
|
||||
func (r *R2) PublicURL(key string) string {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReminderRepo backs the sādhana reminder scheduler: per-user preferences,
|
||||
// the timezone used to fire at local time, and the candidate query the
|
||||
// worker scans each tick.
|
||||
type ReminderRepo struct{ pg *Postgres }
|
||||
|
||||
func NewReminderRepo(pg *Postgres) *ReminderRepo { return &ReminderRepo{pg: pg} }
|
||||
|
||||
// ReminderCandidate is one user the worker evaluates per tick. The time-of-day
|
||||
// windowing + per-day dedupe decision happens in Go (using TZ); the SQL just
|
||||
// supplies the inputs, including whether today's disciplines are already done
|
||||
// (bucketed by UTC day, matching the streak logic).
|
||||
type ReminderCandidate struct {
|
||||
UserID string
|
||||
Email *string
|
||||
TZ string
|
||||
ChantTime string // "HH:MM" local
|
||||
QuoteTime string // "HH:MM" local
|
||||
LastChantOn *time.Time
|
||||
LastQuoteOn *time.Time
|
||||
LastChantDate *time.Time // most recent completed chant day (UTC), nil if never
|
||||
ChantDoneToday bool
|
||||
QuoteDoneToday bool
|
||||
|
||||
// For the evening "incomplete chanting" reminder.
|
||||
RoundsToday int // rounds chanted so far today
|
||||
TargetRounds int // the user's daily target (16 if unset)
|
||||
LastChantIncompleteOn *time.Time // UTC date the evening reminder last fired
|
||||
}
|
||||
|
||||
// SetTimezone records the user's IANA timezone (validated by the caller).
|
||||
func (r *ReminderRepo) SetTimezone(ctx context.Context, userID, tz string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `UPDATE users SET timezone = $1 WHERE id = $2`, tz, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set timezone: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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).
|
||||
//
|
||||
// "Reminder-enabled" now reads notification_prefs: a user drops out of the
|
||||
// scan only when every reminder kind × channel cell (3 × 3) is opted out.
|
||||
// Finer-grained opt-outs (e.g. email off, push on) still match here —
|
||||
// SendReminder enforces the per-channel prefs on delivery.
|
||||
func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
COALESCE(NULLIF(u.timezone, ''), 'UTC'),
|
||||
COALESCE(NULLIF(u.preferred_chanting_time, ''), '06:00'),
|
||||
COALESCE(NULLIF(u.peak_active_time, ''), '21:30'),
|
||||
u.last_chant_reminder_on,
|
||||
u.last_quote_reminder_on,
|
||||
(SELECT max(l.log_date) FROM sadhna_logs l
|
||||
WHERE l.user_id = u.id AND l.chant_done),
|
||||
EXISTS(SELECT 1 FROM sadhna_logs l
|
||||
WHERE l.user_id = u.id AND l.chant_done
|
||||
AND l.log_date = (now() AT TIME ZONE 'UTC')::date),
|
||||
EXISTS(SELECT 1 FROM posts p
|
||||
WHERE p.user_id = u.id
|
||||
AND (p.created_at AT TIME ZONE 'UTC')::date = (now() AT TIME ZONE 'UTC')::date),
|
||||
COALESCE((SELECT l.rounds_completed FROM sadhna_logs l
|
||||
WHERE l.user_id = u.id
|
||||
AND l.log_date = (now() AT TIME ZONE 'UTC')::date), 0),
|
||||
COALESCE(u.daily_chanting_rounds, 16),
|
||||
u.last_chant_incomplete_reminder_on
|
||||
FROM users u
|
||||
WHERE (SELECT count(*) FROM notification_prefs np
|
||||
WHERE np.user_id = u.id
|
||||
AND np.kind IN ('chant_reminder', 'quote_reminder', 'chant_incomplete_reminder')
|
||||
AND np.enabled = FALSE) < 9
|
||||
AND (
|
||||
u.last_chant_reminder_on IS NULL OR u.last_chant_reminder_on < (now() AT TIME ZONE 'UTC')::date
|
||||
OR u.last_quote_reminder_on IS NULL OR u.last_quote_reminder_on < (now() AT TIME ZONE 'UTC')::date
|
||||
OR u.last_chant_incomplete_reminder_on IS NULL OR u.last_chant_incomplete_reminder_on < (now() AT TIME ZONE 'UTC')::date
|
||||
)`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("due candidates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ReminderCandidate
|
||||
for rows.Next() {
|
||||
var c ReminderCandidate
|
||||
if err := rows.Scan(
|
||||
&c.UserID, &c.Email, &c.TZ, &c.ChantTime, &c.QuoteTime,
|
||||
&c.LastChantOn, &c.LastQuoteOn, &c.LastChantDate,
|
||||
&c.ChantDoneToday, &c.QuoteDoneToday,
|
||||
&c.RoundsToday, &c.TargetRounds, &c.LastChantIncompleteOn,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan candidate: %w", err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkChantReminded / MarkQuoteReminded stamp the UTC date a reminder went
|
||||
// out, so the same kind won't fire again that day.
|
||||
func (r *ReminderRepo) MarkChantReminded(ctx context.Context, userID string, utcDate time.Time) error {
|
||||
_, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE users SET last_chant_reminder_on = $1 WHERE id = $2`, utcDate, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *ReminderRepo) MarkQuoteReminded(ctx context.Context, userID string, utcDate time.Time) error {
|
||||
_, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE users SET last_quote_reminder_on = $1 WHERE id = $2`, utcDate, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkChantIncompleteReminded stamps the UTC date the evening "finish your
|
||||
// rounds" reminder went out, so it fires at most once per day.
|
||||
func (r *ReminderRepo) MarkChantIncompleteReminded(ctx context.Context, userID string, utcDate time.Time) error {
|
||||
_, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE users SET last_chant_incomplete_reminder_on = $1 WHERE id = $2`, utcDate, userID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// SadhnaRepo wraps Postgres access for sadhna_logs — the chanting half of
|
||||
// a day's sādhana. The posting half lives in the posts table and is read
|
||||
// via PostRepo.PostedDates, so this repo stays small.
|
||||
type SadhnaRepo struct{ pg *Postgres }
|
||||
|
||||
func NewSadhnaRepo(pg *Postgres) *SadhnaRepo { return &SadhnaRepo{pg: pg} }
|
||||
|
||||
// UserTimezone returns the user's stored IANA timezone, or "" if unset — the
|
||||
// caller falls back to a default. Kept here so the sādhana day boundary can
|
||||
// be the user's local midnight.
|
||||
func (r *SadhnaRepo) UserTimezone(ctx context.Context, userID string) (string, error) {
|
||||
var tz string
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT COALESCE(NULLIF(timezone, ''), '') FROM users WHERE id = $1`, userID,
|
||||
).Scan(&tz)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tz, nil
|
||||
}
|
||||
|
||||
// UpsertToday records (or updates) the given day's chanting progress for
|
||||
// the user. The caller passes the UTC calendar date explicitly (rather
|
||||
// than relying on the DB's CURRENT_DATE) so day bucketing stays identical
|
||||
// to PostedDates' `AT TIME ZONE 'UTC'`. chantDone is decided by the caller
|
||||
// — rounds_completed >= the user's current target — and frozen into the
|
||||
// row so later target changes don't rewrite past days. Returns the row.
|
||||
func (r *SadhnaRepo) UpsertToday(ctx context.Context, userID string, logDate time.Time, rounds int, chantDone bool) (*model.SadhnaLog, error) {
|
||||
var l model.SadhnaLog
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO sadhna_logs (user_id, log_date, rounds_completed, chant_done)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id, log_date) DO UPDATE
|
||||
SET rounds_completed = EXCLUDED.rounds_completed,
|
||||
chant_done = EXCLUDED.chant_done,
|
||||
updated_at = now()
|
||||
RETURNING id, user_id, log_date, rounds_completed, chant_done, created_at, updated_at`,
|
||||
userID, logDate, rounds, chantDone,
|
||||
).Scan(&l.ID, &l.UserID, &l.LogDate, &l.RoundsCompleted, &l.ChantDone, &l.CreatedAt, &l.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert sadhna log: %w", err)
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// ListSince returns the user's sadhna logs on or after `since`, newest
|
||||
// first. The window bounds the streak/growth computation; a few hundred
|
||||
// rows at most.
|
||||
func (r *SadhnaRepo) ListSince(ctx context.Context, userID string, since time.Time) ([]model.SadhnaLog, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT id, user_id, log_date, rounds_completed, chant_done, created_at, updated_at
|
||||
FROM sadhna_logs
|
||||
WHERE user_id = $1 AND log_date >= $2
|
||||
ORDER BY log_date DESC`,
|
||||
userID, since,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list sadhna logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []model.SadhnaLog
|
||||
for rows.Next() {
|
||||
var l model.SadhnaLog
|
||||
if err := rows.Scan(&l.ID, &l.UserID, &l.LogDate, &l.RoundsCompleted, &l.ChantDone, &l.CreatedAt, &l.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan sadhna log: %w", err)
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// rewardsSeedJSON is the catalog source of truth, kept in lockstep with the
|
||||
// repo-root sadhna-rewards/rewards.json (the upload tool writes both). It is
|
||||
// embedded so the BFF can seed/sync the catalog on startup with no external
|
||||
// file dependency at runtime.
|
||||
//
|
||||
//go:embed sadhna_rewards.json
|
||||
var rewardsSeedJSON []byte
|
||||
|
||||
// RewardRepo wraps Postgres access for the sādhana reward catalog and the
|
||||
// per-user grants.
|
||||
type RewardRepo struct{ pg *Postgres }
|
||||
|
||||
func NewRewardRepo(pg *Postgres) *RewardRepo { return &RewardRepo{pg: pg} }
|
||||
|
||||
type rewardsSeedFile struct {
|
||||
Rewards []struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Meaning string `json:"meaning"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Active bool `json:"active"`
|
||||
} `json:"rewards"`
|
||||
}
|
||||
|
||||
// Seed upserts the catalog from the embedded rewards.json by key, so editing
|
||||
// a name/meaning or filling an image_url and redeploying keeps the DB in
|
||||
// sync. Sort follows the file order. Idempotent.
|
||||
func (r *RewardRepo) Seed(ctx context.Context) error {
|
||||
var f rewardsSeedFile
|
||||
if err := json.Unmarshal(rewardsSeedJSON, &f); err != nil {
|
||||
return fmt.Errorf("parse rewards seed: %w", err)
|
||||
}
|
||||
for i, rw := range f.Rewards {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
INSERT INTO sadhna_rewards (key, name, meaning, image_url, active, sort)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
meaning = EXCLUDED.meaning,
|
||||
image_url = EXCLUDED.image_url,
|
||||
active = EXCLUDED.active,
|
||||
sort = EXCLUDED.sort,
|
||||
updated_at = now()`,
|
||||
rw.Key, rw.Name, rw.Meaning, rw.ImageURL, rw.Active, i,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed reward %q: %w", rw.Key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListGiftable returns active rewards that have an image (so a celebration
|
||||
// always has something to show), ordered by sort. This is the gifting pool.
|
||||
func (r *RewardRepo) ListGiftable(ctx context.Context) ([]model.SadhnaReward, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT id, key, name, meaning, image_url, active, sort
|
||||
FROM sadhna_rewards
|
||||
WHERE active = TRUE AND image_url <> ''
|
||||
ORDER BY sort`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list giftable rewards: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []model.SadhnaReward
|
||||
for rows.Next() {
|
||||
var rw model.SadhnaReward
|
||||
if err := rows.Scan(&rw.ID, &rw.Key, &rw.Name, &rw.Meaning, &rw.ImageURL, &rw.Active, &rw.Sort); err != nil {
|
||||
return nil, fmt.Errorf("scan reward: %w", err)
|
||||
}
|
||||
out = append(out, rw)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GrantsByUser returns the user's earned rewards, newest first, with the
|
||||
// reward embedded for direct rendering.
|
||||
func (r *RewardRepo) GrantsByUser(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT g.milestone, g.granted_at,
|
||||
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
|
||||
FROM sadhna_reward_grants g
|
||||
JOIN sadhna_rewards w ON w.id = g.reward_id
|
||||
WHERE g.user_id = $1
|
||||
ORDER BY g.granted_at DESC, g.milestone DESC`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grants by user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []model.SadhnaRewardGrant
|
||||
for rows.Next() {
|
||||
var g model.SadhnaRewardGrant
|
||||
if err := rows.Scan(
|
||||
&g.Milestone, &g.GrantedAt,
|
||||
&g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
|
||||
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan grant: %w", err)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GrantedRewardIDs returns the set of reward ids the user has already
|
||||
// received, for avoiding repeats when picking the next gift.
|
||||
func (r *RewardRepo) GrantedRewardIDs(ctx context.Context, userID string) (map[string]bool, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx,
|
||||
`SELECT reward_id FROM sadhna_reward_grants WHERE user_id = $1`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("granted reward ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("scan reward id: %w", err)
|
||||
}
|
||||
out[id] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// LastGrantedRewardID returns the most recently granted reward id (or "" if
|
||||
// none), used to avoid a back-to-back repeat once the pool has cycled.
|
||||
func (r *RewardRepo) LastGrantedRewardID(ctx context.Context, userID string) (string, error) {
|
||||
var id string
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT reward_id FROM sadhna_reward_grants
|
||||
WHERE user_id = $1 ORDER BY granted_at DESC, milestone DESC LIMIT 1`,
|
||||
userID,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return "", nil // no rows → empty
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// HasMilestone reports whether the user already has a grant for the given
|
||||
// streak milestone (the idempotency guard).
|
||||
func (r *RewardRepo) HasMilestone(ctx context.Context, userID string, milestone int) (bool, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM sadhna_reward_grants WHERE user_id = $1 AND milestone = $2`,
|
||||
userID, milestone,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("has milestone: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// CreateGrant records a gift for a milestone. The unique (user_id, milestone)
|
||||
// makes it idempotent under races; a conflict is a no-op, not an error.
|
||||
func (r *RewardRepo) CreateGrant(ctx context.Context, userID, rewardID string, milestone int) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
INSERT INTO sadhna_reward_grants (user_id, reward_id, milestone)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, milestone) DO NOTHING`,
|
||||
userID, rewardID, milestone,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create grant: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PendingGrant returns the most recent grant the client hasn't celebrated
|
||||
// yet (celebrated_at IS NULL), or nil. Drives the one-time confetti.
|
||||
func (r *RewardRepo) PendingGrant(ctx context.Context, userID string) (*model.SadhnaRewardGrant, error) {
|
||||
var g model.SadhnaRewardGrant
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT g.milestone, g.granted_at,
|
||||
w.id, w.key, w.name, w.meaning, w.image_url, w.active, w.sort
|
||||
FROM sadhna_reward_grants g
|
||||
JOIN sadhna_rewards w ON w.id = g.reward_id
|
||||
WHERE g.user_id = $1 AND g.celebrated_at IS NULL
|
||||
ORDER BY g.granted_at DESC, g.milestone DESC
|
||||
LIMIT 1`,
|
||||
userID,
|
||||
).Scan(
|
||||
&g.Milestone, &g.GrantedAt,
|
||||
&g.Reward.ID, &g.Reward.Key, &g.Reward.Name, &g.Reward.Meaning,
|
||||
&g.Reward.ImageURL, &g.Reward.Active, &g.Reward.Sort,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil // no rows / nothing pending
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
// MarkCelebrated clears all of the user's pending celebrations.
|
||||
func (r *RewardRepo) MarkCelebrated(ctx context.Context, userID string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE sadhna_reward_grants SET celebrated_at = $1
|
||||
WHERE user_id = $2 AND celebrated_at IS NULL`,
|
||||
time.Now().UTC(), userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark celebrated: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"_note": "Sādhana streak rewards catalog. `name` values are evocative suggestions — edit freely. Write each `meaning` (symbolic significance) yourself; blank meanings are fine until you fill them. `key` is the stable slug (R2 object key + DB key); don't rename unless you also rename the source file. `file` is the source image in this folder. `image_url` is filled automatically by the R2 upload step. `active:false` removes a reward from the gifting pool.",
|
||||
"r2_prefix": "sadhna-rewards",
|
||||
"rewards": [
|
||||
{
|
||||
"key": "conchshell",
|
||||
"file": "conchshell.png",
|
||||
"name": "Śaṅkh — The Cosmic Conch",
|
||||
"meaning": "The Divine Conchshell held by Lord Viśnu in one of his hand symbolising purity, auspiciousness, and the primordial sound 'Om'",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/conchshell.png"
|
||||
},
|
||||
{
|
||||
"key": "white-lotus",
|
||||
"file": "white-lotus.png",
|
||||
"name": "Sahasra-dala — Lotus of Pure Being",
|
||||
"meaning": "The symbol of absolute purity of mind, heart, and soul, untouched by external negativity.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/white-lotus.png"
|
||||
},
|
||||
{
|
||||
"key": "pink-lotus",
|
||||
"file": "pink-lotus.png",
|
||||
"name": "Hṛdaya-padma — Lotus of the Surrendered Heart",
|
||||
"meaning": "They symbol of Śri Herself, pink lotus symbolises the loving devotion towards the Lord",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/pink-lotus.png"
|
||||
},
|
||||
{
|
||||
"key": "golden-lotus",
|
||||
"file": "golden-lotus.png",
|
||||
"name": "Hiraṇmaya Padma — The Golden Lotus of Transcendence",
|
||||
"meaning": "The River Ganges flowing from the heavenly planets is full of these golden lotus flowers, and the residents of those planets eat the stems of the those lotuses. Thus they are very beautiful, more so than the inhabitants of any other planet.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/golden-lotus.png"
|
||||
},
|
||||
{
|
||||
"key": "peacock-feather",
|
||||
"file": "peacock-feather.png",
|
||||
"name": "Mayūra-piñcha — Crown of the Cosmic Cowherd",
|
||||
"meaning": "Bedeckded by Lord Kṛṣṇa Himself; the peacock feather is the symbol of absorbing negativity and transforming it into something divine.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/peacock-feather.png"
|
||||
},
|
||||
{
|
||||
"key": "mridangam",
|
||||
"file": "mridangam.png",
|
||||
"name": "Mṛdaṅga — Drum of the Golden Avatāra",
|
||||
"meaning": "Srila Prabhupada's favorite musical instrument. The Heart of the Sankirtan movement.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/mridangam.png"
|
||||
},
|
||||
{
|
||||
"key": "manjira",
|
||||
"file": "manjira.png",
|
||||
"name": "Karatāla — Cymbals of the Eternal Name",
|
||||
"meaning": "The hand cymbals that ring through every kīrtana. Their clear, bright sound keeps the rhythm of the Holy Name and gathers many voices into one — the heartbeat of saṅkīrtana alongside the mṛdaṅga.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/manjira.png"
|
||||
},
|
||||
{
|
||||
"key": "kadamba",
|
||||
"file": "kadamba.png",
|
||||
"name": "Kadamba — Blossom of Eternal Vṛndāvana",
|
||||
"meaning": "A flower so dear to Kṛṣṇa that when Kaliya serpent came, He jumped off from a Kadamba tree. The tree still exists in Kaliya Ghat in Vrndavan.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/kadamba.png"
|
||||
},
|
||||
{
|
||||
"key": "shalagram",
|
||||
"file": "shalagram.png",
|
||||
"name": "Śālagrāma — The Cosmos in a Stone",
|
||||
"meaning": "A self-manifested sacred stone from the Gaṇḍakī River, worshiped as the direct, non-different form of Lord Viṣṇu Himself. The Supreme Lord mercifully appears in stone so that the devotee may bathe, dress, and serve Him with their own hands.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/shalagram.png"
|
||||
},
|
||||
{
|
||||
"key": "srila-prabhupada-footprints",
|
||||
"file": "srila-prabhupada-footprints.png",
|
||||
"name": "Caraṇa-kamala — The Ācārya's Lotus Footprints",
|
||||
"meaning": "The lotus feet of the spiritual master are the boat that carries the soul across the ocean of birth and death. To hold his footprints upon one's head — to follow faithfully in his steps — is both the beginning and the perfection of devotional life.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/srila-prabhupada-footprints.png"
|
||||
},
|
||||
{
|
||||
"key": "kuliya-prasad",
|
||||
"file": "kuliya-prasad.png",
|
||||
"name": "Kuliyā Prasadi — Sri Radharaman Lal's favorite",
|
||||
"meaning": "Butter sweet in a tiny clay pot. Loved by and offered daily to Sri Radharaman Lal",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/kuliya-prasad.png"
|
||||
},
|
||||
{
|
||||
"key": "peda-prasadam",
|
||||
"file": "peda-prasadam.png",
|
||||
"name": "Amṛta-pedā — The Nectar Sweet",
|
||||
"meaning": "Vrndavana's most famous Peda prasadam. Haribol!",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/peda-prasadam.png"
|
||||
},
|
||||
{
|
||||
"key": "rasgulla",
|
||||
"file": "rasgulla.png",
|
||||
"name": "Rasa-gullā — Spheres of Divine Nectar",
|
||||
"meaning": "The only sweet Lord Jagannatha gives to pacify Goddess Lakśmi every year during Rath Yatra.",
|
||||
"active": true,
|
||||
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/rasgulla.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
// SitemapRepo enumerates every public content leaf path for the sitemap.
|
||||
// All queries are projection-only (no content hydration), so a single sweep
|
||||
// over the ~tens-of-thousands of content docs stays cheap. URL construction
|
||||
// mirrors cmd/indexer (the canonical URLs already indexed into Typesense), so
|
||||
// sitemap entries match the real, crawlable pages exactly.
|
||||
type SitemapRepo struct{ db *MongoDB }
|
||||
|
||||
func NewSitemapRepo(db *MongoDB) *SitemapRepo { return &SitemapRepo{db: db} }
|
||||
|
||||
// ContentURLs returns the sorted, de-duplicated set of content leaf paths
|
||||
// (relative — the caller prefixes the origin).
|
||||
func (r *SitemapRepo) ContentURLs(ctx context.Context) ([]string, error) {
|
||||
set := make(map[string]struct{}, 40000)
|
||||
add := func(u string) {
|
||||
if u != "" {
|
||||
set[u] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Verses (bg/sb/cc/sb1962/iso/noi/bs/mm/nbs/…).
|
||||
if err := r.each(ctx, "verses",
|
||||
bson.M{"book": 1, "division": 1, "chapter": 1, "verse": 1},
|
||||
func(m bson.M) {
|
||||
add(verseURL(asString(m["book"]), asString(m["division"]),
|
||||
asInt(m["chapter"]), asInt(m["verse"])))
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Prose chapters (KB volumes, CC chapter summaries, NoD/BBD/… front matter).
|
||||
if err := r.each(ctx, "prose_chapters",
|
||||
bson.M{"book": 1, "volume": 1, "slug": 1, "type": 1},
|
||||
func(m bson.M) {
|
||||
add(proseURL(asString(m["book"]), asInt(m["volume"]),
|
||||
asString(m["slug"]), asString(m["type"])))
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Slug-keyed collections.
|
||||
for _, c := range []struct{ coll, prefix string }{
|
||||
{"letters", "/letters/"},
|
||||
{"transcriptions", "/transcriptions/"},
|
||||
{"essays", "/essays/"},
|
||||
} {
|
||||
if err := r.each(ctx, c.coll, bson.M{"slug": 1}, func(m bson.M) {
|
||||
if s := asString(m["slug"]); s != "" {
|
||||
add(c.prefix + s)
|
||||
}
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Book-scoped chapter collections (book code is the URL slug, per indexer).
|
||||
for _, c := range []struct{ coll, prefix string }{
|
||||
{"journal_chapters", "/journals/"},
|
||||
{"magazine_issues", "/magazines/"},
|
||||
{"other_document_chapters", "/other-documents/"},
|
||||
} {
|
||||
if err := r.each(ctx, c.coll, bson.M{"book": 1, "slug": 1}, func(m bson.M) {
|
||||
b, s := asString(m["book"]), asString(m["slug"])
|
||||
if b != "" && s != "" {
|
||||
add(fmt.Sprintf("%s%s/%s", c.prefix, b, s))
|
||||
}
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Glossary letter pages — one per distinct first letter that has entries.
|
||||
letters := make(map[string]struct{})
|
||||
if err := r.each(ctx, "glossary", bson.M{"term": 1}, func(m bson.M) {
|
||||
if l := firstAlphaLower(asString(m["term"])); l != "" {
|
||||
letters[l] = struct{}{}
|
||||
}
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for l := range letters {
|
||||
add("/glossary/" + l)
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(set))
|
||||
for u := range set {
|
||||
out = append(out, u)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// each streams a collection with the given projection, invoking fn per doc.
|
||||
func (r *SitemapRepo) each(ctx context.Context, coll string, projection bson.M, fn func(bson.M)) error {
|
||||
cur, err := r.db.Collection(coll).Find(ctx, bson.M{}, options.Find().SetProjection(projection))
|
||||
if err != nil {
|
||||
return fmt.Errorf("sitemap query %s: %w", coll, err)
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
for cur.Next(ctx) {
|
||||
var m bson.M
|
||||
if err := cur.Decode(&m); err != nil {
|
||||
continue
|
||||
}
|
||||
fn(m)
|
||||
}
|
||||
return cur.Err()
|
||||
}
|
||||
|
||||
// --- URL builders (mirror cmd/indexer/main.go) ---
|
||||
|
||||
func verseURL(book, division string, chapter, verse int) string {
|
||||
switch book {
|
||||
case "bg":
|
||||
return fmt.Sprintf("/books/bg/%d/%d", chapter, verse)
|
||||
case "sb":
|
||||
if division != "" {
|
||||
return fmt.Sprintf("/books/sb/%s/%d/%d", division, chapter, verse)
|
||||
}
|
||||
return fmt.Sprintf("/books/sb/%d/%d", chapter, verse)
|
||||
case "cc":
|
||||
if division != "" {
|
||||
return fmt.Sprintf("/books/cc/%s/%d/%d", division, chapter, verse)
|
||||
}
|
||||
return fmt.Sprintf("/books/cc/%d/%d", chapter, verse)
|
||||
case "sb1962":
|
||||
return fmt.Sprintf("/books/sb1962/%d/%d", chapter, verse)
|
||||
case "iso":
|
||||
return fmt.Sprintf("/books/iso/%d", chapter)
|
||||
case "noi":
|
||||
return fmt.Sprintf("/books/noi/%d", chapter)
|
||||
case "bs":
|
||||
return fmt.Sprintf("/books/bs/5/%d", verse)
|
||||
case "mm":
|
||||
return fmt.Sprintf("/books/mm/%d/%d", chapter, verse)
|
||||
case "nbs":
|
||||
return fmt.Sprintf("/books/nbs/%d/%d", chapter, verse)
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("/books/%s/%d/%d", book, chapter, verse)
|
||||
}
|
||||
}
|
||||
|
||||
func proseURL(book string, volume int, slug, typ string) string {
|
||||
if slug == "" {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case book == "kb":
|
||||
return fmt.Sprintf("/books/kb/%d/%s", volume, slug)
|
||||
case typ == "chapter-summary":
|
||||
if parts := strings.SplitN(slug, "-", 2); len(parts) == 2 {
|
||||
return fmt.Sprintf("/books/%s/%s/%s", book, parts[0], parts[1])
|
||||
}
|
||||
return fmt.Sprintf("/books/%s/%s", book, slug)
|
||||
default:
|
||||
return fmt.Sprintf("/books/%s/%s", book, slug)
|
||||
}
|
||||
}
|
||||
|
||||
// firstAlphaLower returns the lowercased first ASCII letter of a term, or ""
|
||||
// when it doesn't start with a-z (matches the a–z glossary letter routes).
|
||||
func firstAlphaLower(term string) string {
|
||||
term = strings.TrimSpace(term)
|
||||
if term == "" {
|
||||
return ""
|
||||
}
|
||||
c := term[0]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
if c >= 'a' && c <= 'z' {
|
||||
return string(c)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func asInt(v any) int {
|
||||
switch n := v.(type) {
|
||||
case int32:
|
||||
return int(n)
|
||||
case int64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case float64:
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// social_graph_repo.go bundles the four small repos backing the social
|
||||
// graph — follows, blocks, reports, and notifications. Each is a thin
|
||||
// wrapper over the shared Postgres pool, mirroring the per-domain repo
|
||||
// convention used elsewhere (UserRepo, PostRepo, …). They're grouped in
|
||||
// one file because each is only a handful of queries and they share the
|
||||
// same dependency.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FollowRepo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FollowRepo manages the request-based follows edge table.
|
||||
type FollowRepo struct{ pg *Postgres }
|
||||
|
||||
func NewFollowRepo(pg *Postgres) *FollowRepo { return &FollowRepo{pg: pg} }
|
||||
|
||||
// Request inserts a pending follow edge follower→following. Idempotent:
|
||||
// a repeated request (the edge already exists, pending or accepted) is a
|
||||
// no-op. Returns created=true only when a brand-new pending row was
|
||||
// inserted, so the caller fires the "X requested to follow you"
|
||||
// notification exactly once.
|
||||
func (r *FollowRepo) Request(ctx context.Context, followerID, followingID string) (created bool, err error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
INSERT INTO follows (follower_id, following_id, status)
|
||||
VALUES ($1, $2, 'pending')
|
||||
ON CONFLICT (follower_id, following_id) DO NOTHING`,
|
||||
followerID, followingID,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("request follow: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// Accept flips a pending edge follower→following to accepted and stamps
|
||||
// responded_at. Returns accepted=true only on the transition from
|
||||
// pending (so the "X accepted your request" notification fires once); a
|
||||
// call on an already-accepted or absent edge returns false.
|
||||
func (r *FollowRepo) Accept(ctx context.Context, followerID, followingID string) (accepted bool, err error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE follows
|
||||
SET status = 'accepted', responded_at = now()
|
||||
WHERE follower_id = $1 AND following_id = $2 AND status = 'pending'`,
|
||||
followerID, followingID,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("accept follow: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// Delete removes the edge follower→following regardless of status. Backs
|
||||
// unfollow (accepted), cancel-request (own pending outgoing), reject
|
||||
// (their pending incoming), and remove-follower (their accepted edge to
|
||||
// me). Idempotent.
|
||||
func (r *FollowRepo) Delete(ctx context.Context, followerID, followingID string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
DELETE FROM follows WHERE follower_id = $1 AND following_id = $2`,
|
||||
followerID, followingID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete follow: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns the status of the edge follower→following ('pending' or
|
||||
// 'accepted'), or "" when no edge exists.
|
||||
func (r *FollowRepo) Status(ctx context.Context, followerID, followingID string) (string, error) {
|
||||
var status string
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT status FROM follows WHERE follower_id = $1 AND following_id = $2`,
|
||||
followerID, followingID,
|
||||
).Scan(&status)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("follow status: %w", err)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// CountFollowers counts accepted edges pointing at userID.
|
||||
// ListFollowers returns the accepted followers of userID (users who follow
|
||||
// them), newest first, optionally filtered by a name/handle search `q`.
|
||||
// Returns the page plus the total matching count (for pagination).
|
||||
func (r *FollowRepo) ListFollowers(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||
return r.listRelations(ctx, "f.following_id = $1", "f.follower_id", userID, q, limit, offset)
|
||||
}
|
||||
|
||||
// ListFollowing returns the users userID follows (accepted), newest first,
|
||||
// optionally filtered by `q`. Returns the page plus the total count.
|
||||
func (r *FollowRepo) ListFollowing(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||
return r.listRelations(ctx, "f.follower_id = $1", "f.following_id", userID, q, limit, offset)
|
||||
}
|
||||
|
||||
// listRelations powers both directions: `whereOwn` pins the row to userID on
|
||||
// one side, `joinCol` is the follows column holding the *other* user (the one
|
||||
// we join to users and return). `q` filters by name/handle (ILIKE); empty `q`
|
||||
// disables the filter. The column expressions are fixed internal constants —
|
||||
// never user input — so this stays injection-safe.
|
||||
func (r *FollowRepo) listRelations(
|
||||
ctx context.Context, whereOwn, joinCol, userID, q string, limit, offset int,
|
||||
) ([]model.UserSummary, int, error) {
|
||||
like := "%" + q + "%"
|
||||
|
||||
var total int
|
||||
countSQL := `
|
||||
SELECT count(*)
|
||||
FROM follows f JOIN users u ON u.id = ` + joinCol + `
|
||||
WHERE ` + whereOwn + ` AND f.status = 'accepted'
|
||||
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)`
|
||||
if err := r.pg.Pool.QueryRow(ctx, countSQL, userID, q, like).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count relations: %w", err)
|
||||
}
|
||||
if total == 0 {
|
||||
return []model.UserSummary{}, 0, nil
|
||||
}
|
||||
|
||||
listSQL := `
|
||||
SELECT u.handle, u.name, u.avatar_url
|
||||
FROM follows f JOIN users u ON u.id = ` + joinCol + `
|
||||
WHERE ` + whereOwn + ` AND f.status = 'accepted'
|
||||
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
|
||||
ORDER BY f.created_at DESC, u.handle ASC
|
||||
LIMIT $4 OFFSET $5`
|
||||
rows, err := r.pg.Pool.Query(ctx, listSQL, userID, q, like, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list relations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]model.UserSummary, 0, limit)
|
||||
for rows.Next() {
|
||||
var s model.UserSummary
|
||||
if err := rows.Scan(&s.Handle, &s.Name, &s.AvatarURL); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan relation: %w", err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
func (r *FollowRepo) CountFollowers(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM follows WHERE following_id = $1 AND status = 'accepted'`,
|
||||
userID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count followers: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CountFollowing counts accepted edges originating from userID.
|
||||
func (r *FollowRepo) CountFollowing(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM follows WHERE follower_id = $1 AND status = 'accepted'`,
|
||||
userID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count following: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BlockRepo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BlockRepo manages user_blocks. Block() also tears down any follow
|
||||
// edges in either direction, so it runs in a transaction.
|
||||
type BlockRepo struct{ pg *Postgres }
|
||||
|
||||
func NewBlockRepo(pg *Postgres) *BlockRepo { return &BlockRepo{pg: pg} }
|
||||
|
||||
// Block records blocker→blocked (idempotent) and, in the same tx, deletes
|
||||
// any follow edges between the two in either direction — blocking someone
|
||||
// should sever the relationship both ways.
|
||||
func (r *BlockRepo) Block(ctx context.Context, blockerID, blockedID string) error {
|
||||
tx, err := r.pg.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin block tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO user_blocks (blocker_id, blocked_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (blocker_id, blocked_id) DO NOTHING`,
|
||||
blockerID, blockedID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert block: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM follows
|
||||
WHERE (follower_id = $1 AND following_id = $2)
|
||||
OR (follower_id = $2 AND following_id = $1)`,
|
||||
blockerID, blockedID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("sever follows on block: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit block: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unblock removes the blocker→blocked record. Idempotent.
|
||||
func (r *BlockRepo) Unblock(ctx context.Context, blockerID, blockedID string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
DELETE FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2`,
|
||||
blockerID, blockedID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unblock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlockState reports, in one round-trip, whether a blocked b and whether
|
||||
// b blocked a.
|
||||
func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) {
|
||||
err = r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2),
|
||||
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $2 AND blocked_id = $1)`,
|
||||
a, b,
|
||||
).Scan(&aBlockedB, &bBlockedA)
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("block state: %w", err)
|
||||
}
|
||||
return aBlockedB, bBlockedA, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReportRepo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ReportRepo manages user_reports.
|
||||
type ReportRepo struct{ pg *Postgres }
|
||||
|
||||
func NewReportRepo(pg *Postgres) *ReportRepo { return &ReportRepo{pg: pg} }
|
||||
|
||||
// Create inserts a user-report row.
|
||||
func (r *ReportRepo) Create(ctx context.Context, reporterID, reportedID, category, reason string) (*model.UserReport, error) {
|
||||
rep := &model.UserReport{}
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO user_reports (reporter_id, reported_id, category, reason)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, reporter_id, reported_id, category, reason, created_at, resolved_at`,
|
||||
reporterID, reportedID, category, reason,
|
||||
).Scan(&rep.ID, &rep.ReporterID, &rep.ReportedID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create report: %w", err)
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// CreatePostReport inserts a post-report row.
|
||||
func (r *ReportRepo) CreatePostReport(ctx context.Context, reporterID, postID, category, reason string) (*model.PostReport, error) {
|
||||
rep := &model.PostReport{}
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO post_reports (reporter_id, post_id, category, reason)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, reporter_id, post_id, category, reason, created_at, resolved_at`,
|
||||
reporterID, postID, category, reason,
|
||||
).Scan(&rep.ID, &rep.ReporterID, &rep.PostID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create post report: %w", err)
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NotificationRepo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// NotificationRepo persists notification rows. Reads (list / unread
|
||||
// count / mark-read) will be added when the in-app notification center
|
||||
// ships; for now the table is write-only, feeding the email fan-out and
|
||||
// standing ready for websocket/push.
|
||||
type NotificationRepo struct{ pg *Postgres }
|
||||
|
||||
func NewNotificationRepo(pg *Postgres) *NotificationRepo { return &NotificationRepo{pg: pg} }
|
||||
|
||||
// Create inserts a notification for recipient userID, triggered by
|
||||
// actorID (nil for system notifications), of the given kind.
|
||||
func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *string, kind string) (*model.Notification, error) {
|
||||
n := &model.Notification{}
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO notifications (user_id, actor_id, kind)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, user_id, actor_id, kind, created_at, read_at`,
|
||||
userID, actorID, kind,
|
||||
).Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create notification: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CreateAdminMessage persists an admin → user direct message. Unlike the
|
||||
// event-driven kinds it has no actor and carries free text in `body`.
|
||||
func (r *NotificationRepo) CreateAdminMessage(ctx context.Context, userID, body string) error {
|
||||
if _, err := r.pg.Pool.Exec(ctx,
|
||||
`INSERT INTO notifications (user_id, kind, body) VALUES ($1, 'admin_message', $2)`,
|
||||
userID, body,
|
||||
); err != nil {
|
||||
return fmt.Errorf("create admin message notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByUser returns a recipient's notifications, newest first, with the
|
||||
// actor's display fields joined in for rendering (nil for system
|
||||
// notifications or since-deleted actors). Kinds the user has turned off for
|
||||
// the in-app channel are filtered at read time — the rows still persist (they
|
||||
// back like-aggregation and reappear if the pref is re-enabled).
|
||||
func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count,
|
||||
n.created_at, n.read_at,
|
||||
u.name, u.handle, u.avatar_url,
|
||||
pu.handle, n.body
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON u.id = n.actor_id
|
||||
LEFT JOIN posts p ON p.id = n.post_id
|
||||
LEFT JOIN users pu ON pu.id = p.user_id
|
||||
WHERE n.user_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM notification_prefs np
|
||||
WHERE np.user_id = n.user_id AND np.kind = n.kind
|
||||
AND np.channel = 'in_app' AND np.enabled = FALSE)
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT $2 OFFSET $3`, userID, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list notifications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.Notification{}
|
||||
for rows.Next() {
|
||||
var n model.Notification
|
||||
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.PostID, &n.ActorCount,
|
||||
&n.CreatedAt, &n.ReadAt,
|
||||
&n.ActorName, &n.ActorHandle, &n.ActorAvatar,
|
||||
&n.PostAuthorHandle, &n.Body); err != nil {
|
||||
return nil, fmt.Errorf("scan notification: %w", err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertLike records (or aggregates) a like notification: one row per
|
||||
// (recipient, post). On a repeat like it refreshes the most-recent actor,
|
||||
// bumps actor_count, and re-marks the row unread + most-recent.
|
||||
//
|
||||
// It also throttles the aggregated like *email*: like_email_sent_at is stamped
|
||||
// to now() on the first like and again only once `throttle` has elapsed since
|
||||
// the last email. Returns the new actor_count and whether an email is due this
|
||||
// call (i.e. the stamp was (re)set now) — decided atomically in one statement
|
||||
// to avoid double-emailing under concurrent likes.
|
||||
func (r *NotificationRepo) UpsertLike(ctx context.Context, userID, actorID, postID string, throttle time.Duration) (count int, emailDue bool, err error) {
|
||||
secs := int(throttle.Seconds())
|
||||
err = r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO notifications (user_id, actor_id, kind, post_id, actor_count, like_email_sent_at)
|
||||
VALUES ($1, $2, 'post_like', $3, 1, now())
|
||||
ON CONFLICT (user_id, post_id) WHERE kind = 'post_like'
|
||||
DO UPDATE SET actor_id = EXCLUDED.actor_id,
|
||||
actor_count = notifications.actor_count + 1,
|
||||
read_at = NULL,
|
||||
created_at = now(),
|
||||
like_email_sent_at = CASE
|
||||
WHEN notifications.like_email_sent_at IS NULL
|
||||
OR notifications.like_email_sent_at < now() - make_interval(secs => $4)
|
||||
THEN now()
|
||||
ELSE notifications.like_email_sent_at
|
||||
END
|
||||
RETURNING actor_count, (like_email_sent_at = now())`,
|
||||
userID, actorID, postID, secs,
|
||||
).Scan(&count, &emailDue)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("upsert like notification: %w", err)
|
||||
}
|
||||
return count, emailDue, nil
|
||||
}
|
||||
|
||||
// CreateMention persists a "you were mentioned" notification linked to the
|
||||
// post. Unlike likes there's no aggregation — a mention is a deliberate,
|
||||
// low-volume event.
|
||||
func (r *NotificationRepo) CreateMention(ctx context.Context, userID, actorID, postID string) error {
|
||||
if _, err := r.pg.Pool.Exec(ctx,
|
||||
`INSERT INTO notifications (user_id, actor_id, kind, post_id)
|
||||
VALUES ($1, $2, 'post_mention', $3)`,
|
||||
userID, actorID, postID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("create mention notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateComment persists a comment/reply notification linked to the post.
|
||||
// kind is 'post_comment' (to the post author) or 'comment_reply' (to the
|
||||
// replied-to comment's author). Like mentions, no aggregation.
|
||||
func (r *NotificationRepo) CreateComment(ctx context.Context, userID, actorID, postID, kind string) error {
|
||||
if _, err := r.pg.Pool.Exec(ctx,
|
||||
`INSERT INTO notifications (user_id, actor_id, kind, post_id)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
userID, actorID, kind, postID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("create comment notification: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostAuthorHandle returns the handle of the user who owns the post, for
|
||||
// building a deep-link. Empty string (no error) when the post is gone.
|
||||
func (r *NotificationRepo) PostAuthorHandle(ctx context.Context, postID string) (string, error) {
|
||||
var h string
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT u.handle FROM posts p JOIN users u ON u.id = p.user_id WHERE p.id = $1`,
|
||||
postID).Scan(&h)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("post author handle: %w", err)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// CountUnread returns how many of a recipient's notifications are unread,
|
||||
// excluding kinds the user has turned off for the in-app channel (matching
|
||||
// ListByUser, so the badge never counts rows the feed won't show).
|
||||
func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM notifications n
|
||||
WHERE n.user_id = $1 AND n.read_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM notification_prefs np
|
||||
WHERE np.user_id = n.user_id AND np.kind = n.kind
|
||||
AND np.channel = 'in_app' AND np.enabled = FALSE)`, userID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count unread notifications: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// MarkAllRead stamps read_at on every still-unread notification for the
|
||||
// user. Idempotent.
|
||||
func (r *NotificationRepo) MarkAllRead(ctx context.Context, userID string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE notifications SET read_at = now() WHERE user_id = $1 AND read_at IS NULL`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark notifications read: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StatsRepo runs the aggregate count queries that power /stats. Lives
|
||||
// in its own file rather than spreading COUNT methods across every
|
||||
// domain repo — keeps the at-a-glance "system snapshot" queries in
|
||||
// one place and one trip into the pool.
|
||||
type StatsRepo struct{ pg *Postgres }
|
||||
|
||||
func NewStatsRepo(pg *Postgres) *StatsRepo { return &StatsRepo{pg: pg} }
|
||||
|
||||
// StatsSnapshot bundles totals, last-7-days counts, and feedback
|
||||
// category breakdowns. One value rendered by /stats per request.
|
||||
type StatsSnapshot struct {
|
||||
Users int
|
||||
UsersLast7Days int
|
||||
Posts int
|
||||
PostsLast7Days int
|
||||
Collections int
|
||||
PublicCollections int
|
||||
Bookmarks int
|
||||
Reshares int
|
||||
Feedback int
|
||||
FeedbackLast7Days int
|
||||
FeedbackByCategory []CategoryCount
|
||||
}
|
||||
|
||||
type CategoryCount struct {
|
||||
Category string
|
||||
Count int
|
||||
}
|
||||
|
||||
// Snapshot runs every count needed by /stats. Queries run in series —
|
||||
// the table sizes are modest enough that chasing parallelism here
|
||||
// would buy sub-ms wins for added complexity.
|
||||
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
|
||||
s := &StatsSnapshot{}
|
||||
sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
|
||||
|
||||
scalars := []struct {
|
||||
sql string
|
||||
args []any
|
||||
dst *int
|
||||
}{
|
||||
{`SELECT COUNT(*) FROM users`, nil, &s.Users},
|
||||
{`SELECT COUNT(*) FROM users WHERE created_at >= $1`, []any{sevenAgo}, &s.UsersLast7Days},
|
||||
{`SELECT COUNT(*) FROM posts`, nil, &s.Posts},
|
||||
{`SELECT COUNT(*) FROM posts WHERE created_at >= $1`, []any{sevenAgo}, &s.PostsLast7Days},
|
||||
{`SELECT COUNT(*) FROM collections`, nil, &s.Collections},
|
||||
{`SELECT COUNT(*) FROM collections WHERE is_public`, nil, &s.PublicCollections},
|
||||
{`SELECT COUNT(*) FROM bookmarks`, nil, &s.Bookmarks},
|
||||
{`SELECT COUNT(*) FROM posts WHERE reshared_from_post_id IS NOT NULL`, nil, &s.Reshares},
|
||||
{`SELECT COUNT(*) FROM feedback`, nil, &s.Feedback},
|
||||
{`SELECT COUNT(*) FROM feedback WHERE created_at >= $1`, []any{sevenAgo}, &s.FeedbackLast7Days},
|
||||
}
|
||||
for _, q := range scalars {
|
||||
if err := r.pg.Pool.QueryRow(ctx, q.sql, q.args...).Scan(q.dst); err != nil {
|
||||
return nil, fmt.Errorf("stats scalar %q: %w", q.sql, err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT category, COUNT(*)
|
||||
FROM feedback
|
||||
GROUP BY category
|
||||
ORDER BY COUNT(*) DESC, category ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("feedback by category: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cc CategoryCount
|
||||
if err := rows.Scan(&cc.Category, &cc.Count); err != nil {
|
||||
return nil, fmt.Errorf("scan category: %w", err)
|
||||
}
|
||||
s.FeedbackByCategory = append(s.FeedbackByCategory, cc)
|
||||
}
|
||||
return s, rows.Err()
|
||||
}
|
||||
@@ -29,21 +29,62 @@ func NewUserRepo(pg *Postgres) *UserRepo {
|
||||
return &UserRepo{pg: pg}
|
||||
}
|
||||
|
||||
// userColumns is the canonical column list for SELECT/RETURNING on the
|
||||
// users table, in the exact order scanUser reads them. Keeping the two in
|
||||
// lockstep here is what makes adding a column a single-edit change instead
|
||||
// of a hunt across every query.
|
||||
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||
email_verified_at, token_version, created_at, updated_at, country,
|
||||
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
|
||||
preferred_chanting_time, peak_active_time, banned_at`
|
||||
|
||||
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
|
||||
// scanUser works for single-row and iterated reads alike.
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanUser reads one users row (selected via userColumns) into u.
|
||||
func scanUser(row rowScanner, u *model.User) error {
|
||||
return row.Scan(
|
||||
&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
|
||||
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
|
||||
&u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt,
|
||||
)
|
||||
}
|
||||
|
||||
// ProfileUpdate is the set of fields UpdateProfile can change. Every field
|
||||
// is a pointer so "leave unchanged" (nil) is distinct from "set". For the
|
||||
// nullable string fields, a non-nil pointer to "" means "clear to NULL";
|
||||
// DailyChantingRounds uses *0 as its clear sentinel (1 is the real min).
|
||||
type ProfileUpdate struct {
|
||||
Name *string
|
||||
AvatarURL *string
|
||||
Handle *string
|
||||
Country *string
|
||||
Sampradaya *string // nil=leave, ""=clear, code=set
|
||||
Gender *string
|
||||
Initiated *bool
|
||||
InitiatedAt *string // "YYYY-MM-DD" to set, "" to clear
|
||||
DailyChantingRounds *int // 0 to clear
|
||||
PreferredChantingTime *string
|
||||
PeakActiveTime *string
|
||||
}
|
||||
|
||||
// Create inserts a new user. Returns ErrEmailTaken or ErrHandleTaken on
|
||||
// unique-violation. The caller is responsible for picking a unique handle —
|
||||
// the auth service does this with a retry loop.
|
||||
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name, handle string) (*model.User, error) {
|
||||
const q = `
|
||||
q := `
|
||||
INSERT INTO users (email, password_hash, name, handle)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||
email_verified_at, token_version, created_at, updated_at`
|
||||
RETURNING ` + userColumns
|
||||
|
||||
u := &model.User{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q,
|
||||
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
|
||||
strings.ToLower(strings.TrimSpace(email)), passwordHash, name, handle,
|
||||
).Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
||||
), u)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err, "users_handle_key") {
|
||||
return nil, ErrHandleTaken
|
||||
@@ -62,6 +103,39 @@ func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User,
|
||||
`WHERE email = $1`, strings.ToLower(strings.TrimSpace(email)))
|
||||
}
|
||||
|
||||
// CreateOAuth inserts a user with a NULL password_hash — used by the OAuth
|
||||
// sign-in flow when there's no matching email yet. Email is nullable here:
|
||||
// providers that don't return one (e.g. phone-only Facebook accounts) end
|
||||
// up as users with email=NULL until they fill it in via profile settings.
|
||||
// Same handle-collision retry contract as Create.
|
||||
func (r *UserRepo) CreateOAuth(ctx context.Context, email *string, name, handle string, avatarURL *string, emailVerified bool) (*model.User, error) {
|
||||
q := `
|
||||
INSERT INTO users (email, password_hash, name, handle, avatar_url, email_verified_at)
|
||||
VALUES ($1, NULL, $2, $3, $4, CASE WHEN $5::boolean THEN now() ELSE NULL END)
|
||||
RETURNING ` + userColumns
|
||||
var normEmail *string
|
||||
if email != nil {
|
||||
v := strings.ToLower(strings.TrimSpace(*email))
|
||||
if v != "" {
|
||||
normEmail = &v
|
||||
}
|
||||
}
|
||||
u := &model.User{}
|
||||
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
|
||||
normEmail, name, handle, avatarURL, emailVerified,
|
||||
), u)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err, "users_handle_key") {
|
||||
return nil, ErrHandleTaken
|
||||
}
|
||||
if isUniqueViolation(err, "users_email_key") {
|
||||
return nil, ErrEmailTaken
|
||||
}
|
||||
return nil, fmt.Errorf("create oauth user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// FindByID returns the user with the given id, or ErrUserNotFound.
|
||||
func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) {
|
||||
return r.findOne(ctx, `WHERE id = $1`, id)
|
||||
@@ -74,15 +148,10 @@ func (r *UserRepo) FindByHandle(ctx context.Context, handle string) (*model.User
|
||||
}
|
||||
|
||||
func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) {
|
||||
q := `
|
||||
SELECT id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||
email_verified_at, token_version, created_at, updated_at
|
||||
FROM users ` + where + ` LIMIT 1`
|
||||
q := `SELECT ` + userColumns + ` FROM users ` + where + ` LIMIT 1`
|
||||
|
||||
u := &model.User{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, args...).
|
||||
Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
||||
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
@@ -92,35 +161,107 @@ func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*mod
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// UpdateProfile updates the user's name, avatar, and/or handle. Pass nil to
|
||||
// leave a field unchanged. Returns the updated user.
|
||||
// SetEmail fills in (or replaces) a user's email and resets email_verified_at
|
||||
// to NULL so a fresh verification flow can run. Returns ErrEmailTaken if
|
||||
// another row already owns this address.
|
||||
//
|
||||
// Used by the "add your email later" flow for OAuth users who signed up
|
||||
// without one. The caller (auth service) is expected to follow up by
|
||||
// minting and sending a verification token.
|
||||
func (r *UserRepo) SetEmail(ctx context.Context, id, email string) (*model.User, error) {
|
||||
q := `
|
||||
UPDATE users
|
||||
SET email = $2,
|
||||
email_verified_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING ` + userColumns
|
||||
|
||||
u := &model.User{}
|
||||
err := scanUser(r.pg.Pool.QueryRow(ctx, q, id, strings.ToLower(strings.TrimSpace(email))), u)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if isUniqueViolation(err, "users_email_key") {
|
||||
return nil, ErrEmailTaken
|
||||
}
|
||||
return nil, fmt.Errorf("set email: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// UpdateProfile applies a partial profile patch. Only the non-nil fields of
|
||||
// `p` are touched; the SQL is built dynamically so an untouched column is
|
||||
// never written (keeps updated_at honest and the patch minimal). For the
|
||||
// nullable string fields a non-nil "" clears the column to NULL.
|
||||
//
|
||||
// `handle` collisions surface as ErrHandleTaken so the handler can render
|
||||
// a useful message.
|
||||
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name, avatarURL, handle *string) (*model.User, error) {
|
||||
const q = `
|
||||
UPDATE users
|
||||
SET name = COALESCE($2, name),
|
||||
avatar_url = CASE WHEN $3::boolean THEN $4 ELSE avatar_url END,
|
||||
handle = COALESCE($5, handle),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||
email_verified_at, token_version, created_at, updated_at`
|
||||
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, p ProfileUpdate) (*model.User, error) {
|
||||
sets := make([]string, 0, 10)
|
||||
args := []any{id}
|
||||
|
||||
// $3 is a flag: "did the caller intend to set avatar_url (including to NULL)?"
|
||||
// If avatarURL is nil we leave it alone; otherwise we apply *avatarURL (which
|
||||
// may itself be the empty string — caller decides if "" should map to NULL).
|
||||
updateAvatar := avatarURL != nil
|
||||
var avatarVal *string
|
||||
if updateAvatar && *avatarURL != "" {
|
||||
avatarVal = avatarURL
|
||||
// set appends "col = $N" with a bound value.
|
||||
set := func(col string, val any) {
|
||||
args = append(args, val)
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args)))
|
||||
}
|
||||
// setExpr appends a parameterless SET clause (e.g. "col = NULL").
|
||||
setExpr := func(expr string) { sets = append(sets, expr) }
|
||||
// nullableString handles the nil=leave / ""=clear / value=set trio.
|
||||
nullableString := func(col string, v *string) {
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if *v == "" {
|
||||
setExpr(col + " = NULL")
|
||||
} else {
|
||||
set(col, *v)
|
||||
}
|
||||
}
|
||||
|
||||
if p.Name != nil {
|
||||
set("name", *p.Name)
|
||||
}
|
||||
if p.Handle != nil {
|
||||
set("handle", *p.Handle)
|
||||
}
|
||||
nullableString("avatar_url", p.AvatarURL)
|
||||
nullableString("country", p.Country)
|
||||
nullableString("sampradaya", p.Sampradaya)
|
||||
nullableString("gender", p.Gender)
|
||||
nullableString("preferred_chanting_time", p.PreferredChantingTime)
|
||||
nullableString("peak_active_time", p.PeakActiveTime)
|
||||
if p.Initiated != nil {
|
||||
set("initiated", *p.Initiated)
|
||||
}
|
||||
if p.InitiatedAt != nil {
|
||||
if *p.InitiatedAt == "" {
|
||||
setExpr("initiated_at = NULL")
|
||||
} else {
|
||||
args = append(args, *p.InitiatedAt)
|
||||
sets = append(sets, fmt.Sprintf("initiated_at = $%d::date", len(args)))
|
||||
}
|
||||
}
|
||||
if p.DailyChantingRounds != nil {
|
||||
if *p.DailyChantingRounds == 0 {
|
||||
setExpr("daily_chanting_rounds = NULL")
|
||||
} else {
|
||||
set("daily_chanting_rounds", *p.DailyChantingRounds)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing to change — return the current row rather than a no-op UPDATE.
|
||||
if len(sets) == 0 {
|
||||
return r.findOne(ctx, `WHERE id = $1`, id)
|
||||
}
|
||||
sets = append(sets, "updated_at = now()")
|
||||
|
||||
q := `UPDATE users SET ` + strings.Join(sets, ", ") + ` WHERE id = $1 RETURNING ` + userColumns
|
||||
|
||||
u := &model.User{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal, handle).
|
||||
Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
||||
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
|
||||
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
@@ -133,6 +274,25 @@ func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name, avatarURL
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// SetCountryIfEmpty fills in the account country only when it's still
|
||||
// NULL — used by the best-effort signup capture, so a later manual edit
|
||||
// (or a second capture) never clobbers a value the user chose. `cc` is an
|
||||
// ISO-3166-1 alpha-2 code; callers pass "" to no-op. Errors are the
|
||||
// caller's to swallow (capture is fire-and-forget).
|
||||
func (r *UserRepo) SetCountryIfEmpty(ctx context.Context, id, cc string) error {
|
||||
cc = strings.ToUpper(strings.TrimSpace(cc))
|
||||
if len(cc) != 2 {
|
||||
return nil
|
||||
}
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE users SET country = $2, updated_at = now()
|
||||
WHERE id = $1 AND country IS NULL`, id, cc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set country: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePassword sets a new password hash and bumps token_version so any
|
||||
// existing JWTs immediately become invalid.
|
||||
func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error {
|
||||
@@ -152,6 +312,170 @@ func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search returns users whose name or handle matches `q` (ILIKE
|
||||
// substring). Leading "@" is stripped — callers can pass either
|
||||
// "@foo" or "foo". Ordering bubbles handle-prefix matches above
|
||||
// name-prefix above the rest, then alphabetic by name as the
|
||||
// tiebreaker.
|
||||
//
|
||||
// Postgres ILIKE is fine at our user-table scale (low six figures
|
||||
// max). When the table grows enough to warrant fuzzy / trigram
|
||||
// matching, add a pg_trgm GIN index and switch to similarity().
|
||||
func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]model.User, int, error) {
|
||||
cleaned := strings.TrimPrefix(strings.TrimSpace(q), "@")
|
||||
if cleaned == "" {
|
||||
return []model.User{}, 0, nil
|
||||
}
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
substring := "%" + cleaned + "%"
|
||||
prefix := cleaned + "%"
|
||||
|
||||
// Total matches for pagination — same WHERE as the page query. Cheap
|
||||
// enough at our user-table scale; revisit alongside the pg_trgm switch.
|
||||
var total int
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE name ILIKE $1 OR handle::text ILIKE $1`,
|
||||
substring).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count search users: %w", err)
|
||||
}
|
||||
if total == 0 || offset >= total {
|
||||
return []model.User{}, total, nil
|
||||
}
|
||||
|
||||
// The trailing `handle` tiebreaker makes the ordering total (handle is
|
||||
// unique) so OFFSET paging is stable — without it, rows sharing a rank
|
||||
// and name could reshuffle between pages.
|
||||
sql := `SELECT ` + userColumns + `
|
||||
FROM users
|
||||
WHERE name ILIKE $1 OR handle::text ILIKE $1
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN handle::text ILIKE $2 THEN 0
|
||||
WHEN name ILIKE $2 THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
name,
|
||||
handle
|
||||
LIMIT $3 OFFSET $4`
|
||||
|
||||
rows, err := r.pg.Pool.Query(ctx, sql, substring, prefix, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("search users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.User{}
|
||||
for rows.Next() {
|
||||
var u model.User
|
||||
if err := scanUser(rows, &u); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// AllHandles returns every user's handle, for the public-profile sitemap.
|
||||
// Projection-only (handle is unique + indexed), cheap to sweep. Excludes the
|
||||
// reserved "me" handle (the signed-in user's own private library route).
|
||||
func (r *UserRepo) AllHandles(ctx context.Context) ([]string, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx,
|
||||
`SELECT handle FROM users WHERE handle <> '' AND handle::text <> 'me' ORDER BY handle`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list handles: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
handles := []string{}
|
||||
for rows.Next() {
|
||||
var h string
|
||||
if err := rows.Scan(&h); err != nil {
|
||||
return nil, fmt.Errorf("scan handle: %w", err)
|
||||
}
|
||||
handles = append(handles, h)
|
||||
}
|
||||
return handles, rows.Err()
|
||||
}
|
||||
|
||||
// RecentlyJoined returns up to `limit` users who registered on/after `since`,
|
||||
// newest first, as a discovery list for the feed's "new on the platform"
|
||||
// strip. Excludes the viewer, anyone the viewer already has a follow edge with
|
||||
// (pending or accepted), and anyone involved in a block either direction — so
|
||||
// every suggestion is a fresh account the viewer can actually follow.
|
||||
func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since time.Time, limit int) ([]model.UserSummary, error) {
|
||||
if limit <= 0 || limit > 20 {
|
||||
limit = 5
|
||||
}
|
||||
const q = `
|
||||
SELECT u.id, u.handle, u.name, u.avatar_url
|
||||
FROM users u
|
||||
WHERE u.created_at >= $2
|
||||
AND u.id <> $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM follows f
|
||||
WHERE f.follower_id = $1 AND f.following_id = u.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_blocks b
|
||||
WHERE (b.blocker_id = $1 AND b.blocked_id = u.id)
|
||||
OR (b.blocker_id = u.id AND b.blocked_id = $1)
|
||||
)
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT $3`
|
||||
rows, err := r.pg.Pool.Query(ctx, q, viewerID, since, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recently joined: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.UserSummary{}
|
||||
for rows.Next() {
|
||||
var u model.UserSummary
|
||||
if err := rows.Scan(&u.ID, &u.Handle, &u.Name, &u.AvatarURL); err != nil {
|
||||
return nil, fmt.Errorf("scan recently joined: %w", err)
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetReadingPrefs returns the raw JSONB bytes for the user's reading
|
||||
// prefs (empty object when never set). The frontend owns the shape, so
|
||||
// we round-trip the payload verbatim — no server-side schema enforced.
|
||||
func (r *UserRepo) GetReadingPrefs(ctx context.Context, id string) ([]byte, error) {
|
||||
const q = `SELECT reading_prefs FROM users WHERE id = $1 LIMIT 1`
|
||||
var raw []byte
|
||||
err := r.pg.Pool.QueryRow(ctx, q, id).Scan(&raw)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("get reading prefs: %w", err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
raw = []byte("{}")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// UpdateReadingPrefs overwrites the user's reading prefs blob. Caller is
|
||||
// responsible for validating that `prefsJSON` is well-formed JSON (the
|
||||
// handler does this by re-marshalling the decoded struct before calling
|
||||
// in).
|
||||
func (r *UserRepo) UpdateReadingPrefs(ctx context.Context, id string, prefsJSON []byte) error {
|
||||
const q = `UPDATE users SET reading_prefs = $2::jsonb, updated_at = now() WHERE id = $1`
|
||||
tag, err := r.pg.Pool.Exec(ctx, q, id, prefsJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update reading prefs: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkEmailVerified sets email_verified_at = now() if not already set.
|
||||
func (r *UserRepo) MarkEmailVerified(ctx context.Context, id string) error {
|
||||
const q = `UPDATE users SET email_verified_at = now(), updated_at = now()
|
||||
@@ -223,6 +547,32 @@ func (r *AuthTokenRepo) FindValid(ctx context.Context, tokenHash, purpose string
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// FindValidForUser returns a non-used, non-expired token by user + hash +
|
||||
// purpose. Used by the OTP login flow where the keyspace is small (6
|
||||
// digits) — scoping by user_id prevents a global hash-collision lookup.
|
||||
func (r *AuthTokenRepo) FindValidForUser(ctx context.Context, userID, tokenHash, purpose string) (*model.AuthToken, error) {
|
||||
const q = `
|
||||
SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at
|
||||
FROM auth_tokens
|
||||
WHERE user_id = $1
|
||||
AND token_hash = $2
|
||||
AND purpose = $3
|
||||
AND used_at IS NULL
|
||||
AND expires_at > now()
|
||||
LIMIT 1`
|
||||
|
||||
t := &model.AuthToken{}
|
||||
err := r.pg.Pool.QueryRow(ctx, q, userID, tokenHash, purpose).
|
||||
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("find token: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Consume marks a token as used. After this call the token can never be
|
||||
// honoured again, even if it has not yet expired.
|
||||
func (r *AuthTokenRepo) Consume(ctx context.Context, id string) error {
|
||||
|
||||
+450
-17
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chiMiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
@@ -16,6 +17,10 @@ import (
|
||||
"github.com/prabhupadasays/spsays-bff/internal/worker"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/revalidate"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -31,6 +36,7 @@ type Server struct {
|
||||
|
||||
// Worker lifecycle
|
||||
workerCancel context.CancelFunc
|
||||
workerCtx context.Context // derived in initShortener; cancelled on Close
|
||||
|
||||
// Handlers
|
||||
bookH *handler.BookHandler
|
||||
@@ -43,13 +49,25 @@ type Server struct {
|
||||
otherDocH *handler.OtherDocumentHandler
|
||||
transcriptionH *handler.TranscriptionHandler
|
||||
searchH *handler.SearchHandler
|
||||
sitemapH *handler.SitemapHandler
|
||||
shortURLH *handler.ShortURLHandler
|
||||
authH *handler.AuthHandler
|
||||
oauthH *handler.OAuthHandler
|
||||
userH *handler.UserHandler
|
||||
postH *handler.PostHandler
|
||||
commentH *handler.CommentHandler
|
||||
bookmarkH *handler.BookmarkHandler
|
||||
collectionH *handler.CollectionHandler
|
||||
profileH *handler.ProfileHandler
|
||||
relationH *handler.RelationshipHandler
|
||||
soketiWebhookH *handler.SoketiWebhookHandler
|
||||
notificationH *handler.NotificationHandler
|
||||
sadhnaH *handler.SadhnaHandler
|
||||
pushH *handler.PushHandler
|
||||
reminderH *handler.ReminderHandler
|
||||
feedbackH *handler.FeedbackHandler
|
||||
statsH *handler.StatsHandler
|
||||
adminH *handler.AdminHandler
|
||||
|
||||
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
||||
authIssuer *auth.Issuer
|
||||
@@ -76,6 +94,7 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
|
||||
magazineRepo := repository.NewMagazineRepo(mongoDB)
|
||||
otherDocRepo := repository.NewOtherDocumentRepo(mongoDB)
|
||||
transcriptionRepo := repository.NewTranscriptionRepo(mongoDB)
|
||||
sitemapRepo := repository.NewSitemapRepo(mongoDB)
|
||||
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
@@ -93,6 +112,7 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
|
||||
otherDocH: handler.NewOtherDocumentHandler(otherDocRepo),
|
||||
transcriptionH: handler.NewTranscriptionHandler(transcriptionRepo),
|
||||
searchH: handler.NewSearchHandler(tsClient),
|
||||
sitemapH: handler.NewSitemapHandler(sitemapRepo),
|
||||
}
|
||||
|
||||
// URL shortener: Postgres (canonical) + Redis (queue + hot cache) + background workers.
|
||||
@@ -119,6 +139,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
|
||||
userRepo := repository.NewUserRepo(s.pg)
|
||||
tokenRepo := repository.NewAuthTokenRepo(s.pg)
|
||||
oauthRepo := repository.NewOAuthAccountRepo(s.pg)
|
||||
|
||||
r2, err := repository.NewR2(repository.R2Config{
|
||||
AccountID: cfg.R2AccountID,
|
||||
@@ -143,14 +164,26 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
From: cfg.SMTPFrom,
|
||||
}, logger)
|
||||
|
||||
authSvc := service.NewAuthService(userRepo, tokenRepo, issuer, mail, logger, cfg.FrontendURL)
|
||||
authSvc := service.NewAuthService(userRepo, tokenRepo, oauthRepo, issuer, mail, logger, cfg.FrontendURL)
|
||||
authSvc.SetRevalidator(revalidate.New(cfg.FrontendURL, cfg.RevalidateSecret, logger))
|
||||
userSvc := service.NewUserService(userRepo, r2)
|
||||
|
||||
// Social repositories + services share the same Postgres pool and R2 client.
|
||||
postRepo := repository.NewPostRepo(s.pg)
|
||||
bookmarkRepo := repository.NewBookmarkRepo(s.pg)
|
||||
collectionRepo := repository.NewCollectionRepo(s.pg)
|
||||
postSvc := service.NewPostService(postRepo, r2)
|
||||
// Likes: membership in Postgres, count delta batched through Redis
|
||||
// (s.redis may be nil — LikeRepo then applies counts directly).
|
||||
likeRepo := repository.NewLikeRepo(s.pg, s.redis, logger)
|
||||
mentionRepo := repository.NewMentionRepo(s.pg)
|
||||
commentRepo := repository.NewCommentRepo(s.pg, logger)
|
||||
// Per-user "already served" set so the home feed never repeats posts
|
||||
// across pages (nil-safe — falls back to offset paging without Redis).
|
||||
feedRepo := repository.NewFeedRepo(s.redis, logger)
|
||||
postSvc := service.NewPostService(postRepo, r2, likeRepo, mentionRepo, commentRepo, feedRepo)
|
||||
// Soketi signs webhooks with the app secret; on channel_vacated for a
|
||||
// per-user notifications channel we evict that user's feed seen-set.
|
||||
s.soketiWebhookH = handler.NewSoketiWebhookHandler(cfg.SoketiAppKey, cfg.SoketiAppSecret, feedRepo, logger)
|
||||
bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo)
|
||||
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
|
||||
|
||||
@@ -160,10 +193,105 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
s.userRepo = userRepo
|
||||
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
|
||||
s.userH = handler.NewUserHandler(userSvc)
|
||||
s.postH = handler.NewPostHandler(postSvc)
|
||||
|
||||
// OAuth — wired only when at least one provider is configured. The
|
||||
// registry skips providers with missing creds, so partial configs (e.g.
|
||||
// Google only, no Facebook) work out of the box.
|
||||
providers := oauth.NewRegistry(oauth.Credentials{
|
||||
GoogleClientID: cfg.GoogleOAuthClientID,
|
||||
GoogleClientSecret: cfg.GoogleOAuthClientSecret,
|
||||
FacebookClientID: cfg.FacebookOAuthClientID,
|
||||
FacebookClientSecret: cfg.FacebookOAuthClientSecret,
|
||||
})
|
||||
if len(providers) > 0 {
|
||||
s.oauthH = handler.NewOAuthHandler(
|
||||
authSvc, cookieOpts, providers,
|
||||
[]byte(cfg.JWTSecret), cfg.BFFPublicURL, cfg.FrontendURL,
|
||||
logger,
|
||||
)
|
||||
} else {
|
||||
logger.Info("oauth: no providers configured — /auth/oauth/* will 404")
|
||||
}
|
||||
s.bookmarkH = handler.NewBookmarkHandler(bookmarkSvc)
|
||||
s.collectionH = handler.NewCollectionHandler(collectionSvc, postRepo, issuer, userRepo)
|
||||
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo)
|
||||
|
||||
// Social graph: follows / blocks / reports, plus the notification
|
||||
// "plugin" seam (persists rows + emails today; websocket/push later).
|
||||
// All share the same Postgres pool + mailer. Reports reuse the
|
||||
// feedback admin recipients.
|
||||
followRepo := repository.NewFollowRepo(s.pg)
|
||||
blockRepo := repository.NewBlockRepo(s.pg)
|
||||
reportRepo := repository.NewReportRepo(s.pg)
|
||||
notifRepo := repository.NewNotificationRepo(s.pg)
|
||||
rtClient := realtime.NewSoketiClient(
|
||||
cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret,
|
||||
cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger,
|
||||
)
|
||||
pushRepo := repository.NewPushRepo(s.pg)
|
||||
pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger)
|
||||
s.pushH = handler.NewPushHandler(pushSender, pushRepo)
|
||||
notifPrefsRepo := repository.NewNotificationPrefsRepo(s.pg)
|
||||
notifSvc := service.NewNotificationService(notifRepo, notifPrefsRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL)
|
||||
// Wire the notification seam into the post service so a new like notifies
|
||||
// the author (push + realtime; not the liker themselves).
|
||||
postSvc.SetNotifier(notifSvc)
|
||||
|
||||
// Comments: comment / reply / like / pin / report over the same Postgres
|
||||
// pool, with the notification seam so new comments notify the post author
|
||||
// and replies notify the comment they answer.
|
||||
commentSvc := service.NewCommentService(commentRepo)
|
||||
commentSvc.SetNotifier(notifSvc)
|
||||
s.commentH = handler.NewCommentHandler(commentSvc)
|
||||
|
||||
// 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, notifPrefsRepo, notifSvc, logger, cfg.FrontendURL)
|
||||
s.reminderH = handler.NewReminderHandler(reminderSvc)
|
||||
wctx := s.workerCtx
|
||||
if wctx == nil {
|
||||
wctx = context.Background()
|
||||
}
|
||||
worker.NewReminderWorker(reminderSvc, logger).Run(wctx)
|
||||
|
||||
// Likes worker: folds the pending Redis like-deltas into posts.likes_count
|
||||
// every 15 minutes (no-op when Redis is unavailable).
|
||||
worker.NewLikesWorker(likeRepo, logger).Run(wctx)
|
||||
|
||||
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
|
||||
s.relationH = handler.NewRelationshipHandler(graphSvc)
|
||||
s.notificationH = handler.NewNotificationHandler(notifSvc)
|
||||
s.postH = handler.NewPostHandler(postSvc, graphSvc)
|
||||
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc, likeRepo, mentionRepo, commentRepo)
|
||||
|
||||
// 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)
|
||||
statsRepo := repository.NewStatsRepo(s.pg)
|
||||
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
|
||||
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
|
||||
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo, feedbackSvc)
|
||||
|
||||
// Admin panel — read-mostly listing + notify/reply/resolve actions.
|
||||
// Reuses the notification service (delivery) and mailer (feedback reply).
|
||||
// Gated by RequireAuth + RequireAdmin in the router.
|
||||
adminRepo := repository.NewAdminRepo(s.pg)
|
||||
adminSvc := service.NewAdminService(adminRepo, notifSvc, mail, logger, cfg.FrontendURL)
|
||||
s.adminH = handler.NewAdminHandler(adminSvc)
|
||||
}
|
||||
|
||||
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
||||
@@ -172,6 +300,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 {
|
||||
@@ -228,6 +357,29 @@ func (s *Server) Close() {
|
||||
s.logger.Info("all connections closed")
|
||||
}
|
||||
|
||||
// sitemapUsers — GET /api/v1/sitemap/users → { "urls": ["/u/handle", …] }.
|
||||
// Public-profile sitemap. Reads userRepo at request time (it's wired only
|
||||
// after Postgres init); degrades to an empty list when Postgres is down.
|
||||
func (s *Server) sitemapUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if s.userRepo == nil {
|
||||
response.JSON(w, http.StatusOK, map[string]any{"urls": []string{}})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
handles, err := s.userRepo.AllHandles(ctx)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not build user sitemap")
|
||||
return
|
||||
}
|
||||
urls := make([]string, 0, len(handles))
|
||||
for _, h := range handles {
|
||||
urls = append(urls, "/u/"+h)
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"urls": urls})
|
||||
}
|
||||
|
||||
// authUnavailable is the 503 fallback used when Postgres is down and the
|
||||
// auth handlers couldn't be wired up. Keeps every /auth/* and /users/* URL
|
||||
// returning a structured error rather than 404.
|
||||
@@ -260,9 +412,17 @@ func (s *Server) setupRouter() {
|
||||
r.Use(chiMiddleware.Recoverer)
|
||||
r.Use(chiMiddleware.Compress(5))
|
||||
|
||||
// CORS
|
||||
// CORS — the main frontend plus, when configured, the admin panel (a
|
||||
// separate origin). Empty origins are filtered so the list never widens
|
||||
// unintentionally.
|
||||
allowedOrigins := []string{}
|
||||
for _, o := range []string{s.cfg.FrontendURL, s.cfg.AdminURL} {
|
||||
if o != "" {
|
||||
allowedOrigins = append(allowedOrigins, o)
|
||||
}
|
||||
}
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{s.cfg.FrontendURL},
|
||||
AllowedOrigins: allowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
|
||||
ExposedHeaders: []string{"X-Request-ID"},
|
||||
@@ -276,28 +436,53 @@ func (s *Server) setupRouter() {
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
// Public ops snapshot — aggregate counts + recent feedback rendered
|
||||
// as HTML. Lives outside /api/v1 because it returns HTML, not JSON.
|
||||
// Currently unauthenticated by product decision; gate here if that
|
||||
// ever needs to change.
|
||||
if s.statsH != nil {
|
||||
r.Get("/stats", s.statsH.Render)
|
||||
// One-way per-row feedback actions — POST so a refresh of /stats
|
||||
// can't replay them. Both live at the same public access level as
|
||||
// /stats itself (gate them together if that ever changes).
|
||||
r.Post("/stats/feedback/{id}/acknowledge", s.statsH.AcknowledgeFeedback)
|
||||
r.Post("/stats/feedback/{id}/resolve", s.statsH.ResolveFeedback)
|
||||
}
|
||||
|
||||
// API v1
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// Sitemap — flat list of public content URLs (consumed by the
|
||||
// frontend's daily-cached sitemap routes). Public, read-only.
|
||||
r.Get("/sitemap/content", s.sitemapH.GetContent)
|
||||
// Public user profiles (Postgres) — separate from Mongo content.
|
||||
r.Get("/sitemap/users", s.sitemapUsers)
|
||||
|
||||
// Books
|
||||
r.Route("/books", func(r chi.Router) {
|
||||
r.Get("/", s.bookH.ListBooks)
|
||||
|
||||
// --- Verse books with divisions ---
|
||||
|
||||
// SB — canto divisions
|
||||
// SB — canto divisions (plus prose front-matter segments;
|
||||
// the static /chapters routes take precedence over /{division}).
|
||||
r.Route("/sb", func(r chi.Router) {
|
||||
r.Use(injectBookCode("sb"))
|
||||
r.Get("/", s.bookH.GetBook)
|
||||
r.Get("/chapters", s.bookH.ListProseChapters)
|
||||
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
|
||||
r.Get("/{division}", s.bookH.GetDivisionInfo)
|
||||
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
|
||||
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
|
||||
r.Get("/{division}/{chapter}/{verse}", s.bookH.GetDivisionVerse)
|
||||
})
|
||||
|
||||
// CC — līlā divisions
|
||||
// CC — līlā divisions (plus prose front-matter segments;
|
||||
// the static /chapters routes take precedence over /{division}).
|
||||
r.Route("/cc", func(r chi.Router) {
|
||||
r.Use(injectBookCode("cc"))
|
||||
r.Get("/", s.bookH.GetBook)
|
||||
r.Get("/chapters", s.bookH.ListProseChapters)
|
||||
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
|
||||
r.Get("/{division}", s.bookH.GetDivisionInfo)
|
||||
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
|
||||
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
|
||||
@@ -305,8 +490,8 @@ func (s *Server) setupRouter() {
|
||||
})
|
||||
|
||||
// --- Verse books without divisions ---
|
||||
// BG, SB1962: pure verse books
|
||||
for _, code := range []string{"bg", "sb1962"} {
|
||||
// SB1962: pure verse book
|
||||
for _, code := range []string{"sb1962"} {
|
||||
code := code // capture for closure
|
||||
r.Route("/"+code, func(r chi.Router) {
|
||||
r.Use(injectBookCode(code))
|
||||
@@ -318,8 +503,9 @@ func (s *Server) setupRouter() {
|
||||
}
|
||||
|
||||
// --- Verse + prose intro books (no division) ---
|
||||
// BS, MM, NBS: have both verses and prose intro segments
|
||||
for _, code := range []string{"bs", "mm", "nbs"} {
|
||||
// BG, BS, MM, NBS: verses plus prose front-matter segments
|
||||
// (the static /chapters routes take precedence over /{chapter}).
|
||||
for _, code := range []string{"bg", "bs", "mm", "nbs"} {
|
||||
code := code
|
||||
r.Route("/"+code, func(r chi.Router) {
|
||||
r.Use(injectBookCode(code))
|
||||
@@ -463,28 +649,60 @@ func (s *Server) setupRouter() {
|
||||
}
|
||||
r.Post("/signup", s.authH.SignUp)
|
||||
r.Post("/login", s.authH.LogIn)
|
||||
r.Post("/verify-login-otp", s.authH.VerifyLoginOTP)
|
||||
r.Post("/logout", s.authH.LogOut)
|
||||
r.Post("/forgot-password", s.authH.ForgotPassword)
|
||||
r.Post("/reset-password", s.authH.ResetPassword)
|
||||
r.Post("/verify-email", s.authH.VerifyEmail)
|
||||
|
||||
// OAuth (Google, Facebook, …). GET endpoints because the browser
|
||||
// navigates directly to /start (top-level redirect) and the
|
||||
// provider redirects back to /callback. Both flows set cookies
|
||||
// and 302 — they never return JSON.
|
||||
if s.oauthH != nil {
|
||||
r.Get("/oauth/{provider}/start", s.oauthH.Start)
|
||||
r.Get("/oauth/{provider}/callback", s.oauthH.Callback)
|
||||
}
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/me", s.authH.Me)
|
||||
r.Get("/linked-providers", s.authH.LinkedProviders)
|
||||
r.Post("/email", s.authH.SetEmail)
|
||||
r.Post("/change-password", s.authH.ChangePassword)
|
||||
r.Post("/resend-verification", s.authH.ResendVerification)
|
||||
// Link-provider flow — requires the caller to be signed
|
||||
// in; the state encodes their id so the callback links to
|
||||
// THIS user instead of running the normal sign-in path.
|
||||
// The callback itself is the shared /oauth/{provider}/callback
|
||||
// above — the state's link_user_id field switches behaviour.
|
||||
if s.oauthH != nil {
|
||||
r.Get("/oauth/{provider}/link/start", s.oauthH.LinkStart)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// User profile + avatar — all protected.
|
||||
// User profile — mixed public-read + protected writes.
|
||||
r.Route("/users", func(r chi.Router) {
|
||||
if s.userH == nil {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
// Public read — search by handle / name. Returns a
|
||||
// sanitized payload (no email / role / plan / etc.).
|
||||
r.Get("/search", s.userH.Search)
|
||||
// Protected writes — profile edit, avatar presign,
|
||||
// reading-prefs roaming.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Patch("/me", s.userH.UpdateProfile)
|
||||
r.Post("/me/avatar-presign", s.userH.AvatarPresign)
|
||||
// Reading prefs roam with the account so theme/palette/
|
||||
// font picks follow the user across devices. Body shape
|
||||
// is owned by the frontend; the server treats it as an
|
||||
// opaque JSON object.
|
||||
r.Get("/me/reading-prefs", s.userH.GetReadingPrefs)
|
||||
r.Put("/me/reading-prefs", s.userH.UpdateReadingPrefs)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -494,16 +712,80 @@ func (s *Server) setupRouter() {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
// Public — read individual posts without auth.
|
||||
// Public read — anonymous-OK but populates viewer_id when a
|
||||
// session cookie is present, so the response can include
|
||||
// per-viewer fields like `viewer_has_reshared`.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/{id}", s.postH.Get)
|
||||
// Protected — create / delete / image presign / "my posts".
|
||||
// Paginated "liked by" list — public, like the count itself.
|
||||
r.Get("/{id}/likes", s.postH.ListLikes)
|
||||
// Tagged users on a post — public (read-only for non-authors).
|
||||
r.Get("/{id}/mentions", s.postH.ListMentions)
|
||||
// Comments — public read (viewer_has_liked populated when signed in).
|
||||
if s.commentH != nil {
|
||||
r.Get("/{id}/comments", s.commentH.List)
|
||||
}
|
||||
})
|
||||
// Protected — create / delete / image presign / "my posts" /
|
||||
// reshare actions.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Post("/", s.postH.Create)
|
||||
r.Delete("/{id}", s.postH.Delete)
|
||||
// Hide / unhide from timelines + public collections (owner-only).
|
||||
r.Post("/{id}/hide", s.postH.Hide)
|
||||
r.Delete("/{id}/hide", s.postH.Unhide)
|
||||
r.Put("/{id}/images", s.postH.ReplaceImages)
|
||||
r.Get("/mine", s.postH.ListMine)
|
||||
r.Get("/hidden", s.postH.ListHidden)
|
||||
r.Post("/image-presign", s.postH.ImagePresign)
|
||||
// Reshare is auth-required: anonymous users can't claim
|
||||
// to have reshared anything. The action is idempotent so
|
||||
// double-clicks don't 4xx.
|
||||
r.Post("/{id}/reshare", s.postH.Reshare)
|
||||
r.Delete("/{id}/reshare", s.postH.Unreshare)
|
||||
// Like / unlike — idempotent; membership is written now, the
|
||||
// count is batched into likes_count via Redis.
|
||||
r.Post("/{id}/like", s.postH.Like)
|
||||
r.Delete("/{id}/like", s.postH.Unlike)
|
||||
// Mentions: composer suggestions + author add/remove of tags.
|
||||
r.Get("/mention-suggest", s.postH.MentionSuggest)
|
||||
r.Post("/{id}/mentions", s.postH.AddMention)
|
||||
r.Delete("/{id}/mentions/{userID}", s.postH.RemoveMention)
|
||||
// Report a post (category + optional reason) → admin email.
|
||||
r.Post("/{id}/report", s.postH.Report)
|
||||
// Add a comment / reply (anyone signed in).
|
||||
if s.commentH != nil {
|
||||
r.Post("/{id}/comments", s.commentH.Create)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// --- Social: comments (replies, likes, pin, delete, report) ---
|
||||
// Keyed by comment id; the post-scoped list/create live under /posts.
|
||||
r.Route("/comments", func(r chi.Router) {
|
||||
if s.commentH == nil {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
// Public read — a thread's replies (viewer_has_liked when signed in).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/{id}/replies", s.commentH.Replies)
|
||||
})
|
||||
// Protected — like / pin / delete / report.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Post("/{id}/like", s.commentH.Like)
|
||||
r.Delete("/{id}/like", s.commentH.Unlike)
|
||||
// Pin / unpin — post author only (enforced in the service).
|
||||
r.Post("/{id}/pin", s.commentH.Pin)
|
||||
r.Delete("/{id}/pin", s.commentH.Unpin)
|
||||
// Delete — comment author or post author; removes the thread.
|
||||
r.Delete("/{id}", s.commentH.Delete)
|
||||
// Report a comment (category + optional reason).
|
||||
r.Post("/{id}/report", s.commentH.Report)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -522,12 +804,18 @@ func (s *Server) setupRouter() {
|
||||
// --- Social: collections ---
|
||||
// The single-collection GET is the only route that may be hit
|
||||
// anonymously (public collections); everything else needs auth.
|
||||
// OptionalAuth on the GET lets us populate `viewer_has_reshared`
|
||||
// on each post in the collection's carousel when a viewer is
|
||||
// signed in, without rejecting anonymous traffic.
|
||||
r.Route("/collections", func(r chi.Router) {
|
||||
if s.collectionH == nil {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/{id}", s.collectionH.Get)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Post("/", s.collectionH.Create)
|
||||
@@ -543,13 +831,69 @@ func (s *Server) setupRouter() {
|
||||
})
|
||||
|
||||
// --- Public profile + feed ---
|
||||
// OptionalAuth populates viewer_id when a session cookie is
|
||||
// present, so the feed can carry `viewer_has_reshared` on each
|
||||
// post. Without a cookie the routes still respond — they just
|
||||
// return the anonymous shape (all-false).
|
||||
// Soketi webhooks — server-to-server, authenticated by the HMAC
|
||||
// signature (not a session). channel_vacated on a per-user
|
||||
// notifications channel evicts that user's feed seen-set.
|
||||
if s.soketiWebhookH != nil {
|
||||
r.Post("/webhooks/soketi", s.soketiWebhookH.Handle)
|
||||
}
|
||||
|
||||
// Home feed — the global, personalised feed of posts + its "new on
|
||||
// the platform" discovery strip. Auth-required (the feed is
|
||||
// per-viewer: ordering + relationship flags depend on the caller).
|
||||
r.Route("/feed", func(r chi.Router) {
|
||||
if s.postH == nil {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/", s.postH.Feed)
|
||||
r.Get("/new-users", s.postH.FeedNewUsers)
|
||||
// Clear the per-user seen-set (logout / page unload).
|
||||
r.Post("/reset", s.postH.FeedReset)
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/u", func(r chi.Router) {
|
||||
if s.profileH == nil {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
// Public reads (anonymous-OK; OptionalAuth populates viewer_id
|
||||
// so Get can include the viewer's relationship + the feed can
|
||||
// carry viewer_has_reshared).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/{handle}", s.profileH.Get)
|
||||
r.Get("/{handle}/feed", s.profileH.Feed)
|
||||
r.Get("/{handle}/mentions", s.profileH.MentionsFeed)
|
||||
r.Get("/{handle}/followers", s.profileH.Followers)
|
||||
r.Get("/{handle}/following", s.profileH.Following)
|
||||
})
|
||||
|
||||
// Relationship actions — follow/block/report. All require a
|
||||
// signed-in viewer; each returns the recomputed relationship
|
||||
// + the target's follower/following counts. Follows are
|
||||
// request-based: POST /follow creates a PENDING request that
|
||||
// the target later accepts or rejects.
|
||||
if s.relationH != nil {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Post("/{handle}/follow", s.relationH.Follow)
|
||||
r.Delete("/{handle}/follow", s.relationH.Unfollow)
|
||||
r.Post("/{handle}/follow/accept", s.relationH.AcceptFollow)
|
||||
r.Post("/{handle}/follow/reject", s.relationH.RejectFollow)
|
||||
r.Delete("/{handle}/follower", s.relationH.RemoveFollower)
|
||||
r.Post("/{handle}/block", s.relationH.Block)
|
||||
r.Delete("/{handle}/block", s.relationH.Unblock)
|
||||
r.Post("/{handle}/report", s.relationH.Report)
|
||||
})
|
||||
}
|
||||
})
|
||||
// URL Shortener — Postgres-backed, Redis-queued.
|
||||
// The handler short-circuits to 503 if Postgres or Redis are unavailable.
|
||||
@@ -568,9 +912,98 @@ func (s *Server) setupRouter() {
|
||||
}
|
||||
})
|
||||
|
||||
// Push Notifications
|
||||
// --- Feedback ---
|
||||
// OptionalAuth so authed callers' user_id is captured
|
||||
// automatically (handler reads middleware.UserID), and
|
||||
// anonymous callers are still accepted (they supply
|
||||
// contact_email instead).
|
||||
r.Group(func(r chi.Router) {
|
||||
if s.feedbackH == nil {
|
||||
r.HandleFunc("/feedback", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||
r.Post("/feedback", s.feedbackH.Submit)
|
||||
})
|
||||
|
||||
// Notifications — the signed-in user's in-app feed + unread badge.
|
||||
// Rows are persisted on every follow/accept event; websocket + push
|
||||
// delivery will layer on later. All protected.
|
||||
r.Route("/notifications", func(r chi.Router) {
|
||||
// TODO: register push notification handlers
|
||||
if s.notificationH == nil {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/", s.notificationH.List)
|
||||
r.Get("/unread-count", s.notificationH.UnreadCount)
|
||||
r.Post("/read", s.notificationH.MarkAllRead)
|
||||
r.Get("/prefs", s.notificationH.GetPrefs)
|
||||
r.Put("/prefs", s.notificationH.UpdatePrefs)
|
||||
// Legacy single-toggle endpoints — shims over /prefs, kept until
|
||||
// old clients are gone.
|
||||
r.Get("/email-prefs", s.notificationH.GetEmailPrefs)
|
||||
r.Put("/email-prefs", s.notificationH.UpdateEmailPrefs)
|
||||
})
|
||||
|
||||
// --- 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)
|
||||
}
|
||||
})
|
||||
|
||||
// --- Admin panel --- every route gated by RequireAuth + RequireAdmin
|
||||
// (role == "admin"), so the whole surface is invisible to ordinary
|
||||
// accounts. Additive: no existing route or payload changes.
|
||||
r.Route("/admin", func(r chi.Router) {
|
||||
if s.adminH == nil {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Use(middleware.RequireAdmin(s.userRepo))
|
||||
r.Get("/users", s.adminH.ListUsers)
|
||||
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
|
||||
r.Post("/users/{id}/ban", s.adminH.BanUser)
|
||||
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
||||
r.Get("/posts", s.adminH.ListPosts)
|
||||
r.Post("/posts/{id}/hide", s.adminH.HidePost)
|
||||
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
||||
r.Delete("/posts/{id}", s.adminH.DeletePost)
|
||||
r.Post("/posts/{id}/restore", s.adminH.RestorePost)
|
||||
r.Delete("/comments/{id}", s.adminH.DeleteComment)
|
||||
r.Post("/comments/{id}/restore", s.adminH.RestoreComment)
|
||||
r.Get("/collections", s.adminH.ListCollections)
|
||||
r.Get("/feedback", s.adminH.ListFeedback)
|
||||
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
||||
r.Get("/reports", s.adminH.ListReports)
|
||||
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ErrAdminUserNotFound is returned by NotifyUser when the target id is unknown.
|
||||
var ErrAdminUserNotFound = errors.New("user not found")
|
||||
|
||||
// AdminService backs the admin panel: read-mostly listing plus two actions
|
||||
// (notify a user, reply to feedback) and report resolution. It reuses the
|
||||
// existing NotificationService for delivery and the shared mailer for the
|
||||
// feedback-reply email — no new infrastructure.
|
||||
type AdminService struct {
|
||||
repo *repository.AdminRepo
|
||||
notif *NotificationService
|
||||
mail mailer.Mailer
|
||||
logger *zap.Logger
|
||||
frontendURL string
|
||||
}
|
||||
|
||||
func NewAdminService(repo *repository.AdminRepo, notif *NotificationService, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService {
|
||||
return &AdminService{repo: repo, notif: notif, mail: mail, logger: logger, frontendURL: frontendURL}
|
||||
}
|
||||
|
||||
func (s *AdminService) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
||||
return s.repo.ListUsers(ctx, q, limit, offset)
|
||||
}
|
||||
|
||||
func (s *AdminService) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) {
|
||||
return s.repo.ListPosts(ctx, q, limit, offset)
|
||||
}
|
||||
|
||||
func (s *AdminService) ListCollections(ctx context.Context, q string, limit, offset int) ([]model.AdminCollection, int, error) {
|
||||
return s.repo.ListCollections(ctx, q, limit, offset)
|
||||
}
|
||||
|
||||
func (s *AdminService) ListFeedback(ctx context.Context, status string, limit, offset int) ([]model.AdminFeedback, int, error) {
|
||||
return s.repo.ListFeedback(ctx, status, limit, offset)
|
||||
}
|
||||
|
||||
func (s *AdminService) ListReports(ctx context.Context, reportType, q string, limit, offset int) ([]model.AdminReport, int, error) {
|
||||
return s.repo.ListReports(ctx, reportType, q, limit, offset)
|
||||
}
|
||||
|
||||
// NotifyUser sends a free-text admin message to one user (in-app + push +
|
||||
// email). Returns ErrAdminUserNotFound if the id doesn't exist.
|
||||
func (s *AdminService) NotifyUser(ctx context.Context, userID, title, body string) error {
|
||||
exists, err := s.repo.UserExists(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return ErrAdminUserNotFound
|
||||
}
|
||||
return s.notif.NotifyAdminMessage(ctx, userID, title, body)
|
||||
}
|
||||
|
||||
// ReplyFeedback stores the reply (resolving the row) and emails it to the
|
||||
// submitter when an address is known. Returns found=false if the id is
|
||||
// unknown, and emailed=true when a reply email was actually sent.
|
||||
func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (found, emailed bool, err error) {
|
||||
email, name, ok, err := s.repo.SaveReply(ctx, id, message)
|
||||
if err != nil || !ok {
|
||||
return ok, false, err
|
||||
}
|
||||
if email == nil || strings.TrimSpace(*email) == "" {
|
||||
// Reply stored, but the submitter was anonymous with no contact email.
|
||||
return true, false, nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{
|
||||
"RecipientName": name,
|
||||
"Reply": message,
|
||||
"URL": s.frontendURL,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render feedback reply email failed", zap.Error(err))
|
||||
return true, false, nil
|
||||
}
|
||||
if err := s.mail.Send(strings.TrimSpace(*email), "Re: your feedback — Prabhupāda Says", buf.String()); err != nil {
|
||||
s.logger.Warn("feedback reply email send failed", zap.Error(err))
|
||||
return true, false, nil
|
||||
}
|
||||
return true, true, nil
|
||||
}
|
||||
|
||||
// ResolveReport stamps a report resolved. found=true when this call resolved a
|
||||
// previously-open report (idempotent: a no-op on an already-resolved/unknown id
|
||||
// returns false).
|
||||
func (s *AdminService) ResolveReport(ctx context.Context, reportType, id string) (bool, error) {
|
||||
return s.repo.ResolveReport(ctx, reportType, id)
|
||||
}
|
||||
|
||||
// ─── Moderation actions ──────────────────────────────────────────────────────
|
||||
|
||||
// SetPostHidden hides or unhides any post (found=false if unknown id).
|
||||
func (s *AdminService) SetPostHidden(ctx context.Context, postID string, hidden bool) (bool, error) {
|
||||
return s.repo.SetPostHidden(ctx, postID, hidden)
|
||||
}
|
||||
|
||||
// DeletePost soft-deletes a post (hidden from all reads, restorable).
|
||||
func (s *AdminService) DeletePost(ctx context.Context, postID string) (bool, error) {
|
||||
return s.repo.SetPostDeleted(ctx, postID, true)
|
||||
}
|
||||
|
||||
// RestorePost reverses a soft-delete.
|
||||
func (s *AdminService) RestorePost(ctx context.Context, postID string) (bool, error) {
|
||||
return s.repo.SetPostDeleted(ctx, postID, false)
|
||||
}
|
||||
|
||||
// DeleteComment soft-deletes a comment (restorable).
|
||||
func (s *AdminService) DeleteComment(ctx context.Context, commentID string) (bool, error) {
|
||||
return s.repo.SetCommentDeleted(ctx, commentID, true)
|
||||
}
|
||||
|
||||
// RestoreComment reverses a soft-delete.
|
||||
func (s *AdminService) RestoreComment(ctx context.Context, commentID string) (bool, error) {
|
||||
return s.repo.SetCommentDeleted(ctx, commentID, false)
|
||||
}
|
||||
|
||||
// SetUserBanned suspends or restores an account.
|
||||
func (s *AdminService) SetUserBanned(ctx context.Context, userID string, banned bool) (bool, error) {
|
||||
return s.repo.SetUserBanned(ctx, userID, banned)
|
||||
}
|
||||
|
||||
// feedbackReplyEmailTpl renders the reply email. Reply text is auto-escaped by
|
||||
// html/template, so admin-entered text can't inject markup.
|
||||
var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||
|
||||
<p>Thank you for writing to us. Here's our reply to your feedback:</p>
|
||||
|
||||
<blockquote style="white-space:pre-line;border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
||||
</p>
|
||||
|
||||
<p style="margin-top:1em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/revalidate"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -33,26 +35,35 @@ var (
|
||||
ErrEmailAlreadyVerified = errors.New("email already verified")
|
||||
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||
ErrHandleTaken = errors.New("handle already in use")
|
||||
ErrAccountSuspended = errors.New("this account has been suspended")
|
||||
)
|
||||
|
||||
// AuthService bundles every flow that mutates auth state.
|
||||
type AuthService struct {
|
||||
users *repository.UserRepo
|
||||
tokens *repository.AuthTokenRepo
|
||||
oauth *repository.OAuthAccountRepo // optional — nil disables OAuth flows
|
||||
jwt *auth.Issuer
|
||||
mail mailer.Mailer
|
||||
logger *zap.Logger
|
||||
frontend string // FrontendURL — used to build reset / verify links
|
||||
reval *revalidate.Client // optional — pings sitemap revalidation on new signups
|
||||
|
||||
// TTLs — surfaced as fields so tests can shrink them.
|
||||
PasswordResetTTL time.Duration
|
||||
EmailVerifyTTL time.Duration
|
||||
}
|
||||
|
||||
// NewAuthService wires the dependencies.
|
||||
// SetRevalidator wires the frontend cache-revalidation client. Optional —
|
||||
// when unset, new-signup sitemap pings are simply skipped.
|
||||
func (s *AuthService) SetRevalidator(r *revalidate.Client) { s.reval = r }
|
||||
|
||||
// NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if
|
||||
// the OAuth flows aren't wired (handler then returns 503).
|
||||
func NewAuthService(
|
||||
users *repository.UserRepo,
|
||||
tokens *repository.AuthTokenRepo,
|
||||
oauthRepo *repository.OAuthAccountRepo,
|
||||
jwt *auth.Issuer,
|
||||
mail mailer.Mailer,
|
||||
logger *zap.Logger,
|
||||
@@ -61,6 +72,7 @@ func NewAuthService(
|
||||
return &AuthService{
|
||||
users: users,
|
||||
tokens: tokens,
|
||||
oauth: oauthRepo,
|
||||
jwt: jwt,
|
||||
mail: mail,
|
||||
logger: logger,
|
||||
@@ -70,6 +82,19 @@ func NewAuthService(
|
||||
}
|
||||
}
|
||||
|
||||
// CaptureCountry records the account's country only if it isn't already
|
||||
// set. Best-effort: a blank code is a no-op, and DB errors are logged but
|
||||
// swallowed so the caller (a post-signup goroutine) never fails on it.
|
||||
func (s *AuthService) CaptureCountry(ctx context.Context, userID, country string) {
|
||||
if strings.TrimSpace(country) == "" {
|
||||
return
|
||||
}
|
||||
if err := s.users.SetCountryIfEmpty(ctx, userID, country); err != nil {
|
||||
s.logger.Warn("capture signup country failed",
|
||||
zap.String("user_id", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// AuthResult is the value handlers return on signup/login. Token is the raw
|
||||
// JWT the handler will plant in the response cookie; the User is the public
|
||||
// profile to ship in the response body.
|
||||
@@ -122,13 +147,38 @@ func (s *AuthService) SignUp(ctx context.Context, email, password, name string)
|
||||
s.logger.Warn("verification email failed", zap.String("user", u.ID), zap.Error(err))
|
||||
}
|
||||
|
||||
// New public profile → refresh the sitemap so it's crawlable promptly.
|
||||
s.reval.Trigger("sitemap")
|
||||
|
||||
return s.issueSession(u)
|
||||
}
|
||||
|
||||
// LoginResult is the two-state outcome of LogIn:
|
||||
//
|
||||
// - `Auth != nil` — credentials verified AND email is fine. Caller
|
||||
// sets the session cookie and returns the user.
|
||||
// - `OTPRequired == true` — credentials verified but the account has been
|
||||
// unverified for more than 30 days. An OTP has been emailed; the caller
|
||||
// must collect it from the user and call VerifyLoginOTP to complete the
|
||||
// sign-in. No session cookie is set yet.
|
||||
//
|
||||
// One of the two is always set on a non-error return.
|
||||
type LoginResult struct {
|
||||
Auth *AuthResult
|
||||
OTPRequired bool
|
||||
}
|
||||
|
||||
// staleUnverifiedAge is how long an account can stay unverified before
|
||||
// every subsequent email/password login is gated on an OTP. Anything
|
||||
// shorter and a normal user who simply hasn't checked their inbox yet
|
||||
// would hit the gate; longer and abandoned-but-not-verified accounts
|
||||
// remain a forever-credential. 30 days is the documented requirement.
|
||||
const staleUnverifiedAge = 30 * 24 * time.Hour
|
||||
|
||||
// LogIn authenticates a user by email + password. The error returned for an
|
||||
// unknown email is intentionally identical to a wrong password — never leak
|
||||
// which one was wrong.
|
||||
func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthResult, error) {
|
||||
// which one was wrong. May return early with OTPRequired=true; see LoginResult.
|
||||
func (s *AuthService) LogIn(ctx context.Context, email, password string) (*LoginResult, error) {
|
||||
u, err := s.users.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrUserNotFound) {
|
||||
@@ -136,10 +186,420 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthR
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||
// OAuth-only accounts have no password hash. Treat them as a credential
|
||||
// mismatch so we don't reveal that an OAuth user exists for this email.
|
||||
if u.PasswordHash == nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
// Suspended accounts can't sign in (checked after the password so we don't
|
||||
// reveal ban status to someone who doesn't know the password).
|
||||
if u.BannedAt != nil {
|
||||
return nil, ErrAccountSuspended
|
||||
}
|
||||
|
||||
// OTP gate — unverified email AND account is older than the grace
|
||||
// window. Mint an OTP and signal the caller to collect it. We do NOT
|
||||
// issue a session here.
|
||||
if u.EmailVerifiedAt == nil && time.Since(u.CreatedAt) > staleUnverifiedAge {
|
||||
if u.Email == nil || *u.Email == "" {
|
||||
// No email to send to → fall through to "invalid credentials"
|
||||
// rather than expose this corner case.
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err := s.sendLoginOTP(ctx, u); err != nil {
|
||||
s.logger.Warn("login otp send failed",
|
||||
zap.String("user", u.ID), zap.Error(err))
|
||||
return nil, fmt.Errorf("send login otp: %w", err)
|
||||
}
|
||||
return &LoginResult{OTPRequired: true}, nil
|
||||
}
|
||||
|
||||
res, err := s.issueSession(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoginResult{Auth: res}, nil
|
||||
}
|
||||
|
||||
// VerifyLoginOTP completes a login that was paused by the stale-unverified
|
||||
// OTP gate. Re-checks the password (so an attacker who steals an OTP email
|
||||
// still can't sign in without the password), validates the OTP, flips
|
||||
// email_verified_at to now(), and issues a fresh session.
|
||||
func (s *AuthService) VerifyLoginOTP(ctx context.Context, email, password, otp string) (*AuthResult, error) {
|
||||
u, err := s.users.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if u.PasswordHash == nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if u.BannedAt != nil {
|
||||
return nil, ErrAccountSuspended
|
||||
}
|
||||
|
||||
otpHash := hashToken(otp)
|
||||
tok, err := s.tokens.FindValidForUser(ctx, u.ID, otpHash, model.PurposeLoginOTP)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidOrExpiredLink
|
||||
}
|
||||
if err := s.tokens.Consume(ctx, tok.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.users.MarkEmailVerified(ctx, u.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u2, err := s.users.FindByID(ctx, u.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.issueSession(u2)
|
||||
}
|
||||
|
||||
// sendLoginOTP mints a fresh 6-digit code, invalidates any older pending
|
||||
// codes, stores the SHA-256 hash, and emails the raw code to the user.
|
||||
func (s *AuthService) sendLoginOTP(ctx context.Context, u *model.User) error {
|
||||
if u.Email == nil || *u.Email == "" {
|
||||
return errors.New("no email on file")
|
||||
}
|
||||
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeLoginOTP)
|
||||
|
||||
otp, hash, err := newOTP()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.tokens.Create(ctx, u.ID, model.PurposeLoginOTP, hash, 10*time.Minute); err != nil {
|
||||
return err
|
||||
}
|
||||
body := loginOTPEmail(u.Name, otp)
|
||||
return s.mail.Send(*u.Email, "Your login code — Prabhupāda Says", body)
|
||||
}
|
||||
|
||||
// newOTP returns a freshly-generated 6-digit code + its SHA-256 hex hash.
|
||||
// Modulo bias on a uniform 32-bit source is negligible for the 1M keyspace
|
||||
// (10⁶ / 2³² ≈ 4 parts in 10⁴) and irrelevant against rate-limited online
|
||||
// guessing, which is the only attack model we care about for this size.
|
||||
func newOTP() (raw, hash string, err error) {
|
||||
var b [4]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
n := binary.BigEndian.Uint32(b[:]) % 1_000_000
|
||||
raw = fmt.Sprintf("%06d", n)
|
||||
hash = hashToken(raw)
|
||||
return raw, hash, nil
|
||||
}
|
||||
|
||||
// OAuthProfile is the provider-agnostic profile shape consumed by
|
||||
// SignInWithOAuth. Mirrors pkg/oauth.Profile but lives here so the service
|
||||
// layer doesn't import the OAuth package (which would create a layering
|
||||
// cycle once handlers compose both).
|
||||
type OAuthProfile struct {
|
||||
Provider string
|
||||
ProviderUserID string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
Name string
|
||||
AvatarURL string
|
||||
}
|
||||
|
||||
// ErrOAuthUnavailable means the OAuth repo wasn't wired (no Postgres at
|
||||
// startup). Handler maps to 503.
|
||||
var ErrOAuthUnavailable = errors.New("oauth service unavailable")
|
||||
|
||||
// ErrOAuthAccountClaimed is returned by LinkOAuthToCurrentUser when the
|
||||
// provider identity is already linked to a different user. We refuse to
|
||||
// move the linkage — the rightful owner of the OAuth account at the
|
||||
// provider can sign in via the normal flow.
|
||||
var ErrOAuthAccountClaimed = errors.New("oauth account already linked to another user")
|
||||
|
||||
// LinkOAuthToCurrentUser ties an OAuth provider identity to a specific
|
||||
// already-authenticated user (settings → Connect flow). Refuses to move
|
||||
// the linkage if some other user already claims it.
|
||||
//
|
||||
// Email policy: the user's primary email is NEVER touched by this call.
|
||||
// The provider's email is stored on the oauth_accounts row only for
|
||||
// reference (display in the future, audit). If the user wants to change
|
||||
// their primary email they have to do it explicitly via SetEmail.
|
||||
func (s *AuthService) LinkOAuthToCurrentUser(ctx context.Context, userID string, prof OAuthProfile) error {
|
||||
if s.oauth == nil {
|
||||
return ErrOAuthUnavailable
|
||||
}
|
||||
|
||||
// If this (provider, sub) is already linked, the only valid case is
|
||||
// that it's linked to the SAME user we're trying to link to. Any other
|
||||
// user owning this linkage means the OAuth account was already claimed
|
||||
// (typically the user mis-clicked or it's a different person on a
|
||||
// shared device) — refuse rather than silently steal it.
|
||||
if existing, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
|
||||
if existing.UserID != userID {
|
||||
return ErrOAuthAccountClaimed
|
||||
}
|
||||
// Same user — idempotent: refresh the denormalised profile fields
|
||||
// and return success.
|
||||
_, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||
return err
|
||||
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fresh link.
|
||||
_, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||
return err
|
||||
}
|
||||
|
||||
// SignInWithOAuth turns a provider profile into a session. Three-step lookup:
|
||||
//
|
||||
// 1. Existing linkage on (provider, provider_user_id) → log that user in.
|
||||
// 2. Provider gave us an email AND an existing user has the same email →
|
||||
// link this provider to them, log in.
|
||||
// 3. Otherwise create a new user (no password, email_verified iff we have
|
||||
// an email) and link.
|
||||
//
|
||||
// When the provider returns no email (Facebook accounts with no verified
|
||||
// email on file, accounts that declined the email scope, etc.) we skip
|
||||
// the merge step and create the user with email=NULL. They can add an
|
||||
// email later via PATCH /users/me — the frontend prompts them.
|
||||
//
|
||||
// When we DO have an email, every successful OAuth round-trip is treated
|
||||
// as proof the user controls it — Google + Facebook both verify email
|
||||
// ownership before returning it, so we mark the email as verified.
|
||||
//
|
||||
// In every case the resulting user gets a fresh JWT just like LogIn.
|
||||
func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*AuthResult, error) {
|
||||
if s.oauth == nil {
|
||||
return nil, ErrOAuthUnavailable
|
||||
}
|
||||
|
||||
// 1. Known linkage.
|
||||
if a, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
|
||||
u, err := s.users.FindByID(ctx, a.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Refresh denormalised fields on the linkage row (display name /
|
||||
// avatar may have changed at the provider).
|
||||
_, _ = s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||
if prof.Email != "" {
|
||||
s.ensureEmailVerified(ctx, u)
|
||||
}
|
||||
return s.issueSession(u)
|
||||
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Existing user with the same email → link.
|
||||
// Only attempt this when the provider actually gave us an email. With
|
||||
// no email there's no merge key — we just create a fresh user below.
|
||||
if prof.Email != "" {
|
||||
if u, err := s.users.FindByEmail(ctx, prof.Email); err == nil {
|
||||
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ensureEmailVerified(ctx, u)
|
||||
return s.issueSession(u)
|
||||
} else if !errors.Is(err, repository.ErrUserNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Brand new user. Reuse the handle-collision retry loop.
|
||||
displayName := prof.Name
|
||||
if displayName == "" && prof.Email != "" {
|
||||
displayName = strings.SplitN(prof.Email, "@", 2)[0]
|
||||
}
|
||||
if displayName == "" {
|
||||
// No name AND no email — fall back to the provider name so the user
|
||||
// at least has a non-empty display name they can rename later.
|
||||
displayName = strings.Title(prof.Provider) + " user" //nolint:staticcheck // legacy Title is fine for ASCII labels
|
||||
}
|
||||
base := slugForHandle(prof.Email)
|
||||
if base == "user" && prof.ProviderUserID != "" {
|
||||
// slugForHandle returns "user" for empty input — prefer something
|
||||
// less collision-prone for OAuth-no-email signups.
|
||||
base = "user-" + safeSlugSuffix(prof.ProviderUserID)
|
||||
}
|
||||
emailVerified := prof.Email != "" // can only be verified if we have an email
|
||||
var u *model.User
|
||||
var err error
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
handle := base
|
||||
if attempt > 0 {
|
||||
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
|
||||
}
|
||||
u, err = s.users.CreateOAuth(ctx, optStr(prof.Email), displayName, handle, optStr(prof.AvatarURL), emailVerified)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if errors.Is(err, repository.ErrEmailTaken) {
|
||||
// Race: someone else just claimed this email. Fall back to step 2.
|
||||
// Only reachable when prof.Email != "" (NULL emails never collide).
|
||||
if u2, err2 := s.users.FindByEmail(ctx, prof.Email); err2 == nil {
|
||||
_, _ = s.oauth.Link(ctx, u2.ID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||
s.ensureEmailVerified(ctx, u2)
|
||||
return s.issueSession(u2)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !errors.Is(err, repository.ErrHandleTaken) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create oauth user: %w", err)
|
||||
}
|
||||
|
||||
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// New public profile → refresh the sitemap so it's crawlable promptly.
|
||||
s.reval.Trigger("sitemap")
|
||||
return s.issueSession(u)
|
||||
}
|
||||
|
||||
// safeSlugSuffix reduces an arbitrary provider id to a short ASCII tail
|
||||
// suitable as part of a handle. Provider ids are typically all-digit
|
||||
// (Facebook) or short ASCII (Google sub), so a trailing 8-char window is
|
||||
// plenty and avoids handles getting silly-long.
|
||||
func safeSlugSuffix(s string) string {
|
||||
out := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
return r
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return r + ('a' - 'A')
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}, s)
|
||||
if len(out) > 8 {
|
||||
out = out[len(out)-8:]
|
||||
}
|
||||
if out == "" {
|
||||
return randomHandleSuffix()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// optStr returns nil for empty input, &s otherwise — used to populate
|
||||
// nullable text columns from possibly-empty profile fields.
|
||||
func optStr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// LinkedProviders returns the OAuth provider names already linked to the
|
||||
// caller's account ("google", "facebook"). Used by the settings UI to flip
|
||||
// the per-provider rows between "Connect" and "Connected".
|
||||
func (s *AuthService) LinkedProviders(ctx context.Context, userID string) ([]string, error) {
|
||||
if s.oauth == nil {
|
||||
// OAuth repo not wired (no Postgres). No-op rather than erroring —
|
||||
// settings just shows everything as un-connected.
|
||||
return []string{}, nil
|
||||
}
|
||||
return s.oauth.ListProvidersForUser(ctx, userID)
|
||||
}
|
||||
|
||||
// ChangePassword sets the caller's password, in two modes:
|
||||
//
|
||||
// 1. Account HAS a password — caller must supply the current one. We
|
||||
// verify with bcrypt before replacing, defending against an attacker
|
||||
// who got hold of the session cookie but doesn't know the password.
|
||||
// 2. Account has NO password yet (OAuth-only signup) — caller can set
|
||||
// one without proving anything beyond their existing session. There's
|
||||
// no current password to verify against, and the session already
|
||||
// grants them everything the new password would, so requiring an
|
||||
// email round-trip here would only be friction.
|
||||
//
|
||||
// In both cases token_version is bumped (invalidating every JWT ever
|
||||
// issued to this user) and a fresh session is re-issued so the calling
|
||||
// device stays logged in while every other device is signed out.
|
||||
func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) (*AuthResult, error) {
|
||||
if len(newPassword) < 8 {
|
||||
return nil, ErrWeakPassword
|
||||
}
|
||||
u, err := s.users.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if u.PasswordHash != nil {
|
||||
// Account has a password — current must match before we'll replace it.
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(currentPassword)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
}
|
||||
// If u.PasswordHash == nil we skip the verification: setting an initial
|
||||
// password for an OAuth-only account doesn't require proving anything
|
||||
// beyond the active session.
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
if err := s.users.UpdatePassword(ctx, userID, string(hash)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Re-fetch to pick up the new token_version so the freshly-issued JWT
|
||||
// has the right "tv" claim and the cookie set after this call keeps
|
||||
// the user logged in on this device.
|
||||
u2, err := s.users.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.issueSession(u2)
|
||||
}
|
||||
|
||||
// SetEmail fills in (or replaces) the caller's email address. Used by the
|
||||
// "add your email" flow for OAuth users who signed up without one, and by
|
||||
// any user who later wants to change their address.
|
||||
//
|
||||
// The address is stored as unverified — a verification link is sent to it
|
||||
// immediately. Returns ErrEmailAlreadyTaken if another row already owns it.
|
||||
func (s *AuthService) SetEmail(ctx context.Context, userID, email string) (*model.User, error) {
|
||||
u, err := s.users.SetEmail(ctx, userID, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrEmailTaken) {
|
||||
return nil, ErrEmailAlreadyTaken
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := s.sendVerificationEmail(ctx, u); err != nil {
|
||||
// Verification email failure shouldn't block the set — the user can
|
||||
// always trigger a resend.
|
||||
s.logger.Warn("verification email failed after SetEmail",
|
||||
zap.String("user", u.ID), zap.Error(err))
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ensureEmailVerified flips email_verified_at to now() on the given user
|
||||
// if it's not already set. Called from every SignInWithOAuth branch so a
|
||||
// successful OAuth round-trip immediately satisfies email verification
|
||||
// for an existing-but-unverified account. Mutates u in place so callers
|
||||
// see the updated timestamp without re-fetching.
|
||||
func (s *AuthService) ensureEmailVerified(ctx context.Context, u *model.User) {
|
||||
if u == nil || u.EmailVerifiedAt != nil {
|
||||
return
|
||||
}
|
||||
if err := s.users.MarkEmailVerified(ctx, u.ID); err != nil {
|
||||
s.logger.Warn("mark email verified after oauth", zap.String("user", u.ID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
u.EmailVerifiedAt = &now
|
||||
}
|
||||
|
||||
// Me looks up a user by id and re-verifies the JWT's token_version against
|
||||
@@ -179,9 +639,15 @@ func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) er
|
||||
return err
|
||||
}
|
||||
|
||||
// OAuth-only users may have no email on file — skip the send silently
|
||||
// (the caller already cannot tell whether the email was registered, so
|
||||
// "no email to send to" surfaces the same way).
|
||||
if u.Email == nil || *u.Email == "" {
|
||||
return nil
|
||||
}
|
||||
link := fmt.Sprintf("%s/reset-password?token=%s", s.frontend, raw)
|
||||
body := passwordResetEmail(u.Name, link)
|
||||
if err := s.mail.Send(u.Email, "Reset your Prabhupāda Says password", body); err != nil {
|
||||
if err := s.mail.Send(*u.Email, "Reset your Prabhupāda Says password", body); err != nil {
|
||||
s.logger.Warn("password reset email failed", zap.String("user", u.ID), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
@@ -256,6 +722,12 @@ func (s *AuthService) issueSession(u *model.User) (*AuthResult, error) {
|
||||
}
|
||||
|
||||
func (s *AuthService) sendVerificationEmail(ctx context.Context, u *model.User) error {
|
||||
// Users with no email on file (OAuth signups where the provider didn't
|
||||
// return one) have nothing to send to. The caller is expected to gate
|
||||
// on this, but defensively no-op here too.
|
||||
if u.Email == nil || *u.Email == "" {
|
||||
return nil
|
||||
}
|
||||
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeEmailVerification)
|
||||
|
||||
raw, hash, err := newToken()
|
||||
@@ -268,7 +740,7 @@ func (s *AuthService) sendVerificationEmail(ctx context.Context, u *model.User)
|
||||
|
||||
link := fmt.Sprintf("%s/verify-email?token=%s", s.frontend, raw)
|
||||
body := verifyEmailMessage(u.Name, link)
|
||||
return s.mail.Send(u.Email, "Verify your email — Prabhupāda Says", body)
|
||||
return s.mail.Send(*u.Email, "Verify your email — Prabhupāda Says", body)
|
||||
}
|
||||
|
||||
// newToken returns (rawHex, sha256HexHash). The raw value goes in the
|
||||
@@ -345,6 +817,15 @@ clicking the link below:</p>
|
||||
shown at the top of the site after you log in.</p>`, htmlEscape(name), link, link)
|
||||
}
|
||||
|
||||
func loginOTPEmail(name, otp string) string {
|
||||
return fmt.Sprintf(`<p>Hari Bol %s,</p>
|
||||
<p>Your account on <strong>Prabhupāda Says</strong> has been unverified for a
|
||||
while, so we need a quick one-time code before signing you in:</p>
|
||||
<p style="font-size:1.5rem;font-weight:600;letter-spacing:0.25em;margin:1.25rem 0;">%s</p>
|
||||
<p>This code expires in 10 minutes. If you didn't try to sign in, you can
|
||||
safely ignore this email.</p>`, htmlEscape(name), otp)
|
||||
}
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
"&", "&",
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
// mentionHandleRe matches @handle tokens. The leading boundary (line start or
|
||||
// a non-handle character that isn't itself "@") keeps it from firing inside
|
||||
// emails (foo@bar) or "@@".
|
||||
var mentionHandleRe = regexp.MustCompile(`(^|[^A-Za-z0-9_@])@([A-Za-z0-9_]{1,30})`)
|
||||
|
||||
// mentionHandles pulls unique @handles out of a comment body (capped, so a
|
||||
// pathological body can't fan out unboundedly).
|
||||
func mentionHandles(body string) []string {
|
||||
matches := mentionHandleRe.FindAllStringSubmatch(body, -1)
|
||||
if matches == nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
key := strings.ToLower(m[2])
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, m[2])
|
||||
if len(out) >= 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// maxCommentLen caps a comment/reply body.
|
||||
const maxCommentLen = 2000
|
||||
|
||||
// CommentService is the comment / reply / like / pin / report brain. It sits
|
||||
// over CommentRepo, enforces the rules (non-empty body, author-only pin,
|
||||
// author-or-post-owner delete, one-level threading), and routes new
|
||||
// comments/replies through the NotificationService.
|
||||
type CommentService struct {
|
||||
comments *repository.CommentRepo
|
||||
notif *NotificationService // optional; set via SetNotifier
|
||||
}
|
||||
|
||||
func NewCommentService(comments *repository.CommentRepo) *CommentService {
|
||||
return &CommentService{comments: comments}
|
||||
}
|
||||
|
||||
// SetNotifier wires the notification seam after construction.
|
||||
func (s *CommentService) SetNotifier(n *NotificationService) { s.notif = n }
|
||||
|
||||
// Add posts a comment or reply. parentID nil → a top-level comment (the post
|
||||
// author is notified). Non-nil → a reply: the new comment is flattened onto
|
||||
// the thread root and addressed to the target comment's author, who is the one
|
||||
// notified. Anyone signed in can comment. Returns ErrDuplicateComment when the
|
||||
// same user re-posts identical text in the same scope.
|
||||
func (s *CommentService) Add(ctx context.Context, viewerID, postID string, parentID *string, body string) (*model.Comment, error) {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return nil, errors.New("comment can't be empty")
|
||||
}
|
||||
if len(body) > maxCommentLen {
|
||||
return nil, fmt.Errorf("comment too long (max %d)", maxCommentLen)
|
||||
}
|
||||
// Validate the post exists + grab its author for the comment notification.
|
||||
postAuthor, err := s.comments.PostAuthor(ctx, postID)
|
||||
if err != nil {
|
||||
return nil, err // ErrPostNotFound → 404
|
||||
}
|
||||
|
||||
var rootID *string // thread root the new comment hangs under
|
||||
var replyToUserID *string // who a reply addresses (for the @handle prefix)
|
||||
notifyUserID := postAuthor
|
||||
isReply := parentID != nil
|
||||
|
||||
if isReply {
|
||||
target, err := s.comments.Get(ctx, *parentID, viewerID)
|
||||
if err != nil {
|
||||
return nil, err // ErrCommentNotFound → 404
|
||||
}
|
||||
if target.PostID != postID {
|
||||
return nil, ErrNotFound // parent belongs to a different post
|
||||
}
|
||||
// Flatten: replies always hang under the thread ROOT, never under
|
||||
// another reply — so every reply lives in one thread. The reply is
|
||||
// addressed to whoever we replied to, and they're the one notified.
|
||||
root := target.ID
|
||||
if target.ParentID != nil {
|
||||
root = *target.ParentID
|
||||
}
|
||||
rootID = &root
|
||||
replyToUserID = &target.UserID
|
||||
notifyUserID = target.UserID
|
||||
}
|
||||
|
||||
c, err := s.comments.Create(ctx, postID, viewerID, rootID, replyToUserID, body)
|
||||
if err != nil {
|
||||
return nil, err // ErrDuplicateComment → 409 via mapSocialErr
|
||||
}
|
||||
|
||||
// Fire-and-forget on a detached context so it isn't cancelled when the
|
||||
// request returns. Never notify yourself.
|
||||
if s.notif != nil && notifyUserID != "" && notifyUserID != viewerID {
|
||||
if isReply {
|
||||
go s.notif.NotifyCommentReply(context.Background(), postID, notifyUserID, viewerID)
|
||||
} else {
|
||||
go s.notif.NotifyPostComment(context.Background(), postID, notifyUserID, viewerID)
|
||||
}
|
||||
}
|
||||
|
||||
// Ping anyone @mentioned in the body — skipping the actor and whoever was
|
||||
// already notified above (post/parent author) so they aren't double-pinged.
|
||||
if s.notif != nil {
|
||||
if handles := mentionHandles(body); len(handles) > 0 {
|
||||
go s.notif.NotifyCommentMentions(context.Background(), postID, viewerID, handles, notifyUserID)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// List returns a page of a post's top-level comments (pinned first) + total.
|
||||
func (s *CommentService) List(ctx context.Context, postID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
|
||||
return s.comments.ListTopLevel(ctx, postID, viewerID, limit, offset)
|
||||
}
|
||||
|
||||
// Replies returns a page of a thread's replies (oldest first) + total.
|
||||
func (s *CommentService) Replies(ctx context.Context, parentID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
|
||||
return s.comments.ListReplies(ctx, parentID, viewerID, limit, offset)
|
||||
}
|
||||
|
||||
// Like / Unlike toggle the caller's like on a comment; return the new count.
|
||||
func (s *CommentService) Like(ctx context.Context, viewerID, commentID string) (int, error) {
|
||||
return s.comments.Like(ctx, commentID, viewerID)
|
||||
}
|
||||
|
||||
func (s *CommentService) Unlike(ctx context.Context, viewerID, commentID string) (int, error) {
|
||||
return s.comments.Unlike(ctx, commentID, viewerID)
|
||||
}
|
||||
|
||||
// Pin / Unpin — post author only, and only top-level comments are pinnable.
|
||||
func (s *CommentService) Pin(ctx context.Context, viewerID, commentID string) error {
|
||||
c, err := s.requirePostOwner(ctx, viewerID, commentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.ParentID != nil {
|
||||
return errors.New("only top-level comments can be pinned")
|
||||
}
|
||||
return s.comments.Pin(ctx, c.PostID, commentID)
|
||||
}
|
||||
|
||||
func (s *CommentService) Unpin(ctx context.Context, viewerID, commentID string) error {
|
||||
c, err := s.requirePostOwner(ctx, viewerID, commentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.comments.Unpin(ctx, c.PostID, commentID)
|
||||
}
|
||||
|
||||
// Delete removes a comment (and, for a thread root, its whole thread). Allowed
|
||||
// for the comment's author or the post's author.
|
||||
func (s *CommentService) Delete(ctx context.Context, viewerID, commentID string) error {
|
||||
c, err := s.comments.Get(ctx, commentID, viewerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.UserID != viewerID {
|
||||
author, err := s.comments.PostAuthor(ctx, c.PostID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if author != viewerID {
|
||||
return ErrForbidden
|
||||
}
|
||||
}
|
||||
return s.comments.Delete(ctx, commentID)
|
||||
}
|
||||
|
||||
// Report files a report against a comment. Anyone signed in can report; the
|
||||
// category must be in the (post) report allow-list and the reason is optional.
|
||||
func (s *CommentService) Report(ctx context.Context, viewerID, commentID, category, reason string) error {
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
if !postReportCategories[category] {
|
||||
return errors.New("invalid report category")
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if len(reason) > 2000 {
|
||||
return errors.New("reason too long (max 2000)")
|
||||
}
|
||||
if _, err := s.comments.Get(ctx, commentID, viewerID); err != nil {
|
||||
return err // ErrCommentNotFound → 404
|
||||
}
|
||||
return s.comments.CreateReport(ctx, viewerID, commentID, category, reason)
|
||||
}
|
||||
|
||||
// requirePostOwner loads the comment and verifies the viewer owns the post it
|
||||
// belongs to (used to gate pin/unpin). Returns the comment on success.
|
||||
func (s *CommentService) requirePostOwner(ctx context.Context, viewerID, commentID string) (*model.Comment, error) {
|
||||
c, err := s.comments.Get(ctx, commentID, viewerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
author, err := s.comments.PostAuthor(ctx, c.PostID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if author != viewerID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
// Package service — feedback flow. Sits between the feedback handler
|
||||
// and FeedbackRepo: validates the submission, writes the row, then
|
||||
// fans an HTML notification email out to every address listed in
|
||||
// FEEDBACK_ADMIN_EMAILS. The email is fire-and-forget — transient
|
||||
// send failures are logged and don't fail the request, because the
|
||||
// row is already in the DB and visible on /stats.
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type FeedbackService struct {
|
||||
repo *repository.FeedbackRepo
|
||||
mail mailer.Mailer
|
||||
admins []string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewFeedbackService(repo *repository.FeedbackRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
|
||||
return &FeedbackService{repo: repo, mail: mail, admins: admins, logger: logger}
|
||||
}
|
||||
|
||||
// Allowed categories — kept lowercase in storage so dashboards group
|
||||
// cleanly. The frontend dropdown sends one of these exact strings.
|
||||
var feedbackCategories = map[string]bool{
|
||||
"bug": true,
|
||||
"feature": true,
|
||||
"general": true,
|
||||
"other": true,
|
||||
}
|
||||
|
||||
// Submit persists a feedback row and fires the admin email.
|
||||
//
|
||||
// userID is nil for anonymous submissions; contactEmail is the
|
||||
// optional reply-to in that case. For authed users we ignore whatever
|
||||
// the form sent and read the canonical email off the joined user row
|
||||
// when assembling the notification — that keeps the reply path
|
||||
// trustworthy (the form can't lie about who's writing).
|
||||
func (s *FeedbackService) Submit(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
if !feedbackCategories[category] {
|
||||
return nil, errors.New("invalid category")
|
||||
}
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return nil, errors.New("message is required")
|
||||
}
|
||||
if len(message) > 5000 {
|
||||
return nil, errors.New("message too long (max 5000)")
|
||||
}
|
||||
|
||||
if userID == nil {
|
||||
if contactEmail == nil || strings.TrimSpace(*contactEmail) == "" {
|
||||
return nil, errors.New("contact_email is required when not logged in")
|
||||
}
|
||||
trimmed := strings.TrimSpace(*contactEmail)
|
||||
contactEmail = &trimmed
|
||||
} else {
|
||||
// Authed: ignore client-supplied contact_email; user row owns it.
|
||||
contactEmail = nil
|
||||
}
|
||||
|
||||
f, err := s.repo.Create(ctx, userID, category, message, contactEmail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Hydrate the joined author fields for the email template. Failing
|
||||
// the lookup isn't worth failing the request — we fall back to
|
||||
// "anonymous" rather than 500ing on the user.
|
||||
if userID != nil {
|
||||
if full, err := s.repo.FindByID(ctx, f.ID); err == nil {
|
||||
f = full
|
||||
} else {
|
||||
s.logger.Warn("feedback hydrate failed; sending email without author info",
|
||||
zap.String("feedback_id", f.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
s.notifyAdmins(f)
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// ErrAlreadyResolved is returned by Resolve when the row was already
|
||||
// closed (or doesn't exist). The handler maps it to a benign redirect
|
||||
// rather than an error — the end state the admin wanted is already true.
|
||||
var ErrAlreadyResolved = errors.New("feedback already resolved")
|
||||
|
||||
// ErrAlreadyAcknowledged is the acknowledge-path counterpart. Same
|
||||
// benign-redirect treatment in the handler.
|
||||
var ErrAlreadyAcknowledged = errors.New("feedback already acknowledged")
|
||||
|
||||
// Acknowledge marks a feedback row acknowledged (one-way) and, if a
|
||||
// recipient address is known, emails the author that the team has seen
|
||||
// their feedback and — if it needs work — the devs have started, with a
|
||||
// second email promised on completion.
|
||||
//
|
||||
// Mirrors Resolve: MarkAcknowledged's `acknowledged_at IS NULL` guard
|
||||
// guarantees the email fires at most once; the email is fire-and-forget.
|
||||
func (s *FeedbackService) Acknowledge(ctx context.Context, id string) error {
|
||||
acked, err := s.repo.MarkAcknowledged(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !acked {
|
||||
return ErrAlreadyAcknowledged
|
||||
}
|
||||
|
||||
f, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
s.logger.Warn("feedback acknowledged but hydrate failed; skipping email",
|
||||
zap.String("feedback_id", id), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
to := feedbackReplyTo(f)
|
||||
if to == "" {
|
||||
s.logger.Info("feedback acknowledged; no recipient address, skipping email",
|
||||
zap.String("feedback_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := renderFeedbackAcknowledgedEmail(f)
|
||||
if err != nil {
|
||||
s.logger.Warn("render feedback-acknowledged email failed", zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if err := s.mail.Send(to, "We've received your feedback — Prabhupāda Says", body); err != nil {
|
||||
s.logger.Warn("feedback-acknowledged email send failed",
|
||||
zap.String("to", to), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve marks a feedback row resolved (one-way) and, if a recipient
|
||||
// address is known, sends the author a Vaishnava-styled thank-you note.
|
||||
//
|
||||
// The DB update is the source of truth: MarkResolved's `resolved_at IS
|
||||
// NULL` guard guarantees the email fires at most once even under
|
||||
// concurrent button presses. The email is fire-and-forget — a send
|
||||
// failure logs but doesn't un-resolve the row.
|
||||
func (s *FeedbackService) Resolve(ctx context.Context, id string) error {
|
||||
resolved, err := s.repo.MarkResolved(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !resolved {
|
||||
return ErrAlreadyResolved
|
||||
}
|
||||
|
||||
// Re-read with author joins so we know where to send the thank-you.
|
||||
f, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
// Row IS resolved; we just can't assemble the email. Log and
|
||||
// succeed — the resolve itself stuck.
|
||||
s.logger.Warn("feedback resolved but hydrate failed; skipping thank-you email",
|
||||
zap.String("feedback_id", id), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
to := feedbackReplyTo(f)
|
||||
if to == "" {
|
||||
// No email on file (anonymous submitter who left no contact, or
|
||||
// a since-deleted account). Nothing to send — that's fine.
|
||||
s.logger.Info("feedback resolved; no recipient address, skipping thank-you email",
|
||||
zap.String("feedback_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := renderFeedbackResolvedEmail(f)
|
||||
if err != nil {
|
||||
s.logger.Warn("render feedback-resolved email failed", zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if err := s.mail.Send(to, "Your feedback has been addressed — Prabhupāda Says", body); err != nil {
|
||||
s.logger.Warn("feedback-resolved email send failed",
|
||||
zap.String("to", to), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// feedbackReplyTo picks the address to thank: the authed submitter's
|
||||
// account email if present, else the anonymous contact_email, else "".
|
||||
func feedbackReplyTo(f *model.Feedback) string {
|
||||
if f.AuthorEmail != nil && strings.TrimSpace(*f.AuthorEmail) != "" {
|
||||
return strings.TrimSpace(*f.AuthorEmail)
|
||||
}
|
||||
if f.ContactEmail != nil && strings.TrimSpace(*f.ContactEmail) != "" {
|
||||
return strings.TrimSpace(*f.ContactEmail)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// notifyAdmins fires one HTML email per configured admin. Each
|
||||
// failure logs and falls through — the row is persisted so a missed
|
||||
// email is annoying but not data-losing.
|
||||
func (s *FeedbackService) notifyAdmins(f *model.Feedback) {
|
||||
if len(s.admins) == 0 {
|
||||
s.logger.Info("feedback received but no admin recipients configured",
|
||||
zap.String("feedback_id", f.ID))
|
||||
return
|
||||
}
|
||||
body, err := renderFeedbackEmail(f)
|
||||
if err != nil {
|
||||
s.logger.Warn("render feedback email failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
subject := fmt.Sprintf("[Feedback · %s] %s", f.Category, summarize(f.Message, 60))
|
||||
for _, to := range s.admins {
|
||||
if err := s.mail.Send(to, subject, body); err != nil {
|
||||
s.logger.Warn("feedback email send failed",
|
||||
zap.String("to", to), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func summarize(s string, max int) string {
|
||||
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max-1]) + "…"
|
||||
}
|
||||
|
||||
var feedbackEmailTpl = template.Must(template.New("feedback").Parse(`<p><strong>New feedback received</strong></p>
|
||||
<p><strong>Category:</strong> {{.Category}}<br>
|
||||
<strong>Submitted:</strong> {{.CreatedAt}}<br>
|
||||
<strong>From:</strong> {{.From}}</p>
|
||||
<p><strong>Message:</strong></p>
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Message}}</pre>
|
||||
<p style="color:#999;font-size:12px">Feedback ID: {{.ID}}</p>`))
|
||||
|
||||
func renderFeedbackEmail(f *model.Feedback) (string, error) {
|
||||
var from string
|
||||
switch {
|
||||
case f.AuthorHandle != nil && *f.AuthorHandle != "":
|
||||
from = fmt.Sprintf("%s (@%s)", derefOr(f.AuthorName, "—"), *f.AuthorHandle)
|
||||
if f.AuthorEmail != nil && *f.AuthorEmail != "" {
|
||||
from += " — " + *f.AuthorEmail
|
||||
}
|
||||
case f.ContactEmail != nil && *f.ContactEmail != "":
|
||||
from = "anonymous — " + *f.ContactEmail
|
||||
default:
|
||||
from = "anonymous"
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err := feedbackEmailTpl.Execute(&buf, map[string]any{
|
||||
"Category": f.Category,
|
||||
"Message": f.Message,
|
||||
"From": from,
|
||||
"CreatedAt": f.CreatedAt.Format("2006-01-02 15:04:05 MST"),
|
||||
"ID": f.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func derefOr(p *string, fallback string) string {
|
||||
if p == nil || *p == "" {
|
||||
return fallback
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// feedbackResolvedEmailTpl is the author-facing thank-you sent when an
|
||||
// admin marks their feedback resolved. Vaishnava-styled, short, and
|
||||
// quotes back the exact feedback the devotee submitted so they know
|
||||
// precisely what was addressed. Base font is 17px for comfortable
|
||||
// reading on phones.
|
||||
var feedbackResolvedEmailTpl = template.Must(template.New("feedbackResolved").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏</p>
|
||||
|
||||
<p>The feedback you shared with us has been addressed and the necessary
|
||||
changes are now live:</p>
|
||||
|
||||
<blockquote style="margin:1em 0;padding:0.75em 1em;border-left:3px solid #d6b27a;background:#faf5ea;color:#5a4a32;font-style:italic;white-space:pre-wrap">{{.Message}}</blockquote>
|
||||
|
||||
<p>Thank you for helping us serve Śrīla Prabhupāda's <em>vāṇī</em> more
|
||||
faithfully. Please continue using
|
||||
<a href="https://prabhupadasays.org" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a>
|
||||
with even greater enthusiasm.</p>
|
||||
|
||||
<p style="margin-top:1.5em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
|
||||
// feedbackAcknowledgedEmailTpl is the author-facing "we've seen it"
|
||||
// note sent when an admin presses "Acknowledge". Same Vaishnava styling
|
||||
// and 17px base as the resolved email, quotes the submission back, and
|
||||
// promises a follow-up email on completion.
|
||||
var feedbackAcknowledgedEmailTpl = template.Must(template.New("feedbackAcknowledged").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .Name}}, {{.Name}}{{end}} 🙏</p>
|
||||
|
||||
<p>Thank you for the feedback you shared with us:</p>
|
||||
|
||||
<blockquote style="margin:1em 0;padding:0.75em 1em;border-left:3px solid #d6b27a;background:#faf5ea;color:#5a4a32;font-style:italic;white-space:pre-wrap">{{.Message}}</blockquote>
|
||||
|
||||
<p>We acknowledge your feedback and have noted it carefully. If it calls
|
||||
for a new feature or a fix, our dev team has already begun working on it.
|
||||
You'll receive another email from us once it has been implemented or
|
||||
resolved.</p>
|
||||
|
||||
<p>Thank you for helping us serve Śrīla Prabhupāda's <em>vāṇī</em> more
|
||||
faithfully. Please continue using
|
||||
<a href="https://prabhupadasays.org" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a>
|
||||
with even greater enthusiasm.</p>
|
||||
|
||||
<p style="margin-top:1.5em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
|
||||
func renderFeedbackAcknowledgedEmail(f *model.Feedback) (string, error) {
|
||||
name := ""
|
||||
if f.AuthorName != nil {
|
||||
name = strings.TrimSpace(*f.AuthorName)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := feedbackAcknowledgedEmailTpl.Execute(&buf, map[string]any{
|
||||
"Name": name,
|
||||
"Message": strings.TrimSpace(f.Message),
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func renderFeedbackResolvedEmail(f *model.Feedback) (string, error) {
|
||||
// Prefer the account display name; fall back to no name (the
|
||||
// template drops the comma cleanly when Name is empty).
|
||||
name := ""
|
||||
if f.AuthorName != nil {
|
||||
name = strings.TrimSpace(*f.AuthorName)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := feedbackResolvedEmailTpl.Execute(&buf, map[string]any{
|
||||
"Name": name,
|
||||
"Message": strings.TrimSpace(f.Message),
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
// Package service — NotificationService is the single seam every
|
||||
// user-facing event flows through ("the notification plugin"). Today it
|
||||
// does two things per event:
|
||||
//
|
||||
// 1. Persists a row in `notifications` (so an in-app notification center
|
||||
// and, later, websocket + push delivery can read the same history).
|
||||
// 2. Sends an email to the recipient via the shared mailer.
|
||||
//
|
||||
// Everything here is fire-and-forget from the caller's perspective: a
|
||||
// follow request must succeed even if the email bounces or the insert
|
||||
// fails, so Notify* never returns an error — failures are logged. When
|
||||
// websocket/push land they hook in right here alongside the email send.
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Notification kinds — short machine strings stored in notifications.kind.
|
||||
const (
|
||||
NotifFollowRequest = "follow_request"
|
||||
NotifFollowAccepted = "follow_accepted"
|
||||
NotifPostLike = "post_like"
|
||||
NotifPostMention = "post_mention"
|
||||
NotifPostComment = "post_comment"
|
||||
NotifCommentReply = "comment_reply"
|
||||
NotifCommentMention = "comment_mention"
|
||||
|
||||
// Reminder kinds are delivered live (email + push + a websocket toast)
|
||||
// but NOT persisted — they're ephemeral nudges, not history.
|
||||
NotifChantReminder = "chant_reminder"
|
||||
NotifQuoteReminder = "quote_reminder"
|
||||
NotifChantIncomplete = "chant_incomplete_reminder"
|
||||
)
|
||||
|
||||
// PrefCategories groups notification kinds into the coarser event categories
|
||||
// the Settings matrix exposes — storage stays kind-level (so backfilled
|
||||
// opt-outs map exactly), but the UI reads/writes whole categories.
|
||||
var PrefCategories = map[string][]string{
|
||||
"follows": {NotifFollowRequest, NotifFollowAccepted},
|
||||
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
|
||||
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
||||
}
|
||||
|
||||
// PrefChannels is every delivery channel a matrix cell can address.
|
||||
var PrefChannels = []string{
|
||||
repository.ChannelInApp, repository.ChannelEmail, repository.ChannelPush,
|
||||
}
|
||||
|
||||
type NotificationService struct {
|
||||
notifs *repository.NotificationRepo
|
||||
prefs *repository.NotificationPrefsRepo
|
||||
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, prefs *repository.NotificationPrefsRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, push *PushSender, logger *zap.Logger, frontendURL string) *NotificationService {
|
||||
return &NotificationService{
|
||||
notifs: notifs,
|
||||
prefs: prefs,
|
||||
users: users,
|
||||
mail: mail,
|
||||
rt: rt,
|
||||
push: push,
|
||||
logger: logger,
|
||||
frontendURL: strings.TrimRight(frontendURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
// disabledPrefs loads the recipient's notification opt-outs. Fails open
|
||||
// (empty set): absence means enabled, and a prefs lookup hiccup must never
|
||||
// silence delivery for users who opted out of nothing.
|
||||
func (s *NotificationService) disabledPrefs(ctx context.Context, userID string) map[string]struct{} {
|
||||
if s.prefs == nil {
|
||||
return nil
|
||||
}
|
||||
set, err := s.prefs.DisabledSet(ctx, userID)
|
||||
if err != nil {
|
||||
s.logger.Warn("load notification prefs failed", zap.String("user", userID), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// prefOff reports whether (kind, channel) is opted out in a disabled set.
|
||||
func prefOff(set map[string]struct{}, kind, channel string) bool {
|
||||
_, off := set[repository.PrefKey(kind, channel)]
|
||||
return off
|
||||
}
|
||||
|
||||
// List returns the caller's notifications, newest first.
|
||||
func (s *NotificationService) List(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return s.notifs.ListByUser(ctx, userID, limit, offset)
|
||||
}
|
||||
|
||||
// UnreadCount returns the caller's unread notification count (for the bell
|
||||
// badge).
|
||||
func (s *NotificationService) UnreadCount(ctx context.Context, userID string) (int, error) {
|
||||
return s.notifs.CountUnread(ctx, userID)
|
||||
}
|
||||
|
||||
// MarkAllRead marks all of the caller's notifications read.
|
||||
func (s *NotificationService) MarkAllRead(ctx context.Context, userID string) error {
|
||||
return s.notifs.MarkAllRead(ctx, userID)
|
||||
}
|
||||
|
||||
// ErrUnknownPref marks a PrefMatrix/SetPref call with an unrecognized
|
||||
// category or channel (a client bug — surfaced as a 400).
|
||||
var ErrUnknownPref = errors.New("unknown notification preference")
|
||||
|
||||
// PrefMatrix returns the resolved Settings matrix: category → channel →
|
||||
// enabled. A cell reads enabled only when every kind in the category is
|
||||
// enabled on that channel, so a backfilled single-kind opt-out (like emails)
|
||||
// shows as the category being off rather than silently on.
|
||||
func (s *NotificationService) PrefMatrix(ctx context.Context, userID string) (map[string]map[string]bool, error) {
|
||||
off, err := s.prefs.DisabledSet(ctx, userID)
|
||||
if err != nil {
|
||||
// The handler sanitizes this into a generic 500 — log the real
|
||||
// cause here or it's invisible in production.
|
||||
s.logger.Error("notification prefs: load failed", zap.String("user", userID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
matrix := make(map[string]map[string]bool, len(PrefCategories))
|
||||
for category, kinds := range PrefCategories {
|
||||
row := make(map[string]bool, len(PrefChannels))
|
||||
for _, ch := range PrefChannels {
|
||||
enabled := true
|
||||
for _, kind := range kinds {
|
||||
if prefOff(off, kind, ch) {
|
||||
enabled = false
|
||||
break
|
||||
}
|
||||
}
|
||||
row[ch] = enabled
|
||||
}
|
||||
matrix[category] = row
|
||||
}
|
||||
return matrix, nil
|
||||
}
|
||||
|
||||
// SetPref writes one category × channel cell, fanning out to every kind in
|
||||
// the category, and keeps the legacy users columns in sync for old clients.
|
||||
func (s *NotificationService) SetPref(ctx context.Context, userID, category, channel string, enabled bool) error {
|
||||
kinds, ok := PrefCategories[category]
|
||||
if !ok {
|
||||
return ErrUnknownPref
|
||||
}
|
||||
if !slices.Contains(PrefChannels, channel) {
|
||||
return ErrUnknownPref
|
||||
}
|
||||
var err error
|
||||
if enabled {
|
||||
err = s.prefs.Enable(ctx, userID, kinds, []string{channel})
|
||||
} else {
|
||||
err = s.prefs.Disable(ctx, userID, kinds, []string{channel})
|
||||
}
|
||||
if err == nil {
|
||||
err = s.prefs.SyncLegacyColumns(ctx, userID)
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("notification prefs: write failed",
|
||||
zap.String("user", userID), zap.String("category", category),
|
||||
zap.String("channel", channel), zap.Error(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// LikeEmailPref / SetLikeEmailPref back the legacy like-email endpoint —
|
||||
// now a shim over the prefs table (post_like × email) that dual-writes the
|
||||
// old users column. Kept until the matrix UI is fully rolled out.
|
||||
func (s *NotificationService) LikeEmailPref(ctx context.Context, userID string) (bool, error) {
|
||||
off, err := s.prefs.DisabledSet(ctx, userID)
|
||||
if err != nil {
|
||||
s.logger.Error("notification prefs: load failed", zap.String("user", userID), zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
return !prefOff(off, NotifPostLike, repository.ChannelEmail), nil
|
||||
}
|
||||
|
||||
func (s *NotificationService) SetLikeEmailPref(ctx context.Context, userID string, enabled bool) error {
|
||||
kinds, channels := []string{NotifPostLike}, []string{repository.ChannelEmail}
|
||||
var err error
|
||||
if enabled {
|
||||
err = s.prefs.Enable(ctx, userID, kinds, channels)
|
||||
} else {
|
||||
err = s.prefs.Disable(ctx, userID, kinds, channels)
|
||||
}
|
||||
if err == nil {
|
||||
err = s.prefs.SyncLegacyColumns(ctx, userID)
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("notification prefs: like-email write failed",
|
||||
zap.String("user", userID), zap.Error(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// NotifyFollowRequest tells `recipientID` that `actorID` asked to follow
|
||||
// them. Persists the row and emails the recipient (if they have an email
|
||||
// on file).
|
||||
func (s *NotificationService) NotifyFollowRequest(ctx context.Context, recipientID, actorID string) {
|
||||
s.dispatch(ctx, recipientID, actorID, NotifFollowRequest,
|
||||
"%s requested to follow you — Prabhupāda Says")
|
||||
}
|
||||
|
||||
// NotifyFollowAccepted tells `recipientID` that `actorID` accepted their
|
||||
// follow request (i.e. the recipient can now see actor's activity).
|
||||
func (s *NotificationService) NotifyFollowAccepted(ctx context.Context, recipientID, actorID string) {
|
||||
s.dispatch(ctx, recipientID, actorID, NotifFollowAccepted,
|
||||
"%s accepted your follow request — Prabhupāda Says")
|
||||
}
|
||||
|
||||
// NotifyPostLike tells the post author that `likerID` liked their post. Sent
|
||||
// only when the liker isn't the author (callers also guard). Unlike follows,
|
||||
// likes are high-volume, so this delivers a persisted row + realtime nudge +
|
||||
// Web Push but NO email. The push is tagged per-post so repeated likes on the
|
||||
// same post collapse into one notification rather than stacking.
|
||||
func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, authorID, likerID string) {
|
||||
if authorID == "" || likerID == "" || authorID == likerID {
|
||||
return
|
||||
}
|
||||
off := s.disabledPrefs(ctx, authorID)
|
||||
|
||||
// Aggregate into the single (author, post) like-notification row — always,
|
||||
// even with in-app off: the row carries the like aggregation + email
|
||||
// throttle state, and read-time filtering keeps it out of the feed.
|
||||
count, emailDue, err := s.notifs.UpsertLike(ctx, authorID, likerID, postID, likeEmailThrottle)
|
||||
if err != nil {
|
||||
s.logger.Warn("aggregate like notification failed",
|
||||
zap.String("recipient", authorID), zap.Error(err))
|
||||
count, emailDue = 1, false
|
||||
}
|
||||
|
||||
actorUser, _ := s.users.FindByID(ctx, likerID)
|
||||
who := "Someone"
|
||||
if actorUser != nil {
|
||||
who = actorDisplay(actorUser)
|
||||
}
|
||||
body := likeMessage(who, count)
|
||||
|
||||
// Realtime nudge — carry actor_count so the toast can read "and N others".
|
||||
if s.rt != nil && !prefOff(off, NotifPostLike, repository.ChannelInApp) {
|
||||
payload := map[string]any{"kind": NotifPostLike, "actor_count": count}
|
||||
if actorUser != nil {
|
||||
payload["actor_name"] = actorDisplay(actorUser)
|
||||
payload["actor_handle"] = actorUser.Handle
|
||||
payload["actor_avatar_url"] = actorUser.AvatarURL
|
||||
}
|
||||
if err := s.rt.Trigger(ctx, "notifications."+authorID, "notification", payload); err != nil {
|
||||
s.logger.Warn("realtime like publish failed", zap.String("recipient", authorID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Tapping a notification opens the author's own feed anchored to the post.
|
||||
author, _ := s.users.FindByID(ctx, authorID)
|
||||
url := s.frontendURL + "/notifications"
|
||||
if author != nil {
|
||||
url = s.frontendURL + "/u/" + author.Handle + "/posts#post-" + postID
|
||||
}
|
||||
|
||||
// Web Push — tagged per-post so repeated likes collapse into one.
|
||||
if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) {
|
||||
s.push.SendToUser(ctx, authorID, PushPayload{
|
||||
Title: "New Like",
|
||||
Body: body,
|
||||
URL: url,
|
||||
Icon: s.iconURL(),
|
||||
Tag: NotifPostLike + ":" + postID,
|
||||
})
|
||||
}
|
||||
|
||||
// Aggregated email — only when the throttle says one is due (so a burst of
|
||||
// likes yields a single email), the author has an address, and they
|
||||
// haven't opted out. `body` already aggregates ("X and N others …").
|
||||
if !emailDue || author == nil || author.Email == nil || strings.TrimSpace(*author.Email) == "" {
|
||||
return
|
||||
}
|
||||
if prefOff(off, NotifPostLike, repository.ChannelEmail) {
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := likeEmailTpl.Execute(&buf, map[string]any{
|
||||
"RecipientName": author.Name,
|
||||
"Message": body,
|
||||
"URL": url,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render like email failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := s.mail.Send(strings.TrimSpace(*author.Email), "New likes on your post — Prabhupāda Says", buf.String()); err != nil {
|
||||
s.logger.Warn("like email send failed", zap.String("to", *author.Email), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// likeEmailThrottle is the minimum gap between aggregated like emails for a
|
||||
// single post — a burst of likes within the window produces one email.
|
||||
const likeEmailThrottle = 6 * time.Hour
|
||||
|
||||
// NotifyPostMention tells `mentionedID` that `actorID` (the post author)
|
||||
// tagged them in a post. Persisted row + realtime + Web Push; no email
|
||||
// (push/in-app is enough, and a post can tag many people). Skips self-tags.
|
||||
func (s *NotificationService) NotifyPostMention(ctx context.Context, mentionedID, actorID, postID string) {
|
||||
if mentionedID == "" || actorID == "" || mentionedID == actorID {
|
||||
return
|
||||
}
|
||||
off := s.disabledPrefs(ctx, mentionedID)
|
||||
if err := s.notifs.CreateMention(ctx, mentionedID, actorID, postID); err != nil {
|
||||
s.logger.Warn("persist mention notification failed",
|
||||
zap.String("recipient", mentionedID), zap.Error(err))
|
||||
}
|
||||
actorUser, _ := s.users.FindByID(ctx, actorID)
|
||||
if !prefOff(off, NotifPostMention, repository.ChannelInApp) {
|
||||
s.publishRealtime(ctx, mentionedID, NotifPostMention, actorUser)
|
||||
}
|
||||
|
||||
if s.push == nil || prefOff(off, NotifPostMention, repository.ChannelPush) {
|
||||
return
|
||||
}
|
||||
who := "Someone"
|
||||
url := s.frontendURL + "/notifications"
|
||||
if actorUser != nil {
|
||||
who = actorDisplay(actorUser)
|
||||
url = s.frontendURL + "/u/" + actorUser.Handle + "/posts#post-" + postID
|
||||
}
|
||||
s.push.SendToUser(ctx, mentionedID, PushPayload{
|
||||
Title: "You were mentioned",
|
||||
Body: who + " mentioned you in a post",
|
||||
URL: url,
|
||||
Icon: s.iconURL(),
|
||||
Tag: NotifPostMention + ":" + postID,
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyPostComment tells the post author that `commenterID` commented on
|
||||
// their post. NotifyCommentReply tells `recipientID` (the author of the
|
||||
// comment being replied to) that `replierID` replied. Both: persisted row +
|
||||
// realtime + Web Push, no email (comments are interactive, not digest-worthy).
|
||||
// Self-actions are skipped.
|
||||
func (s *NotificationService) NotifyPostComment(ctx context.Context, postID, authorID, commenterID string) {
|
||||
s.notifyComment(ctx, NotifPostComment, postID, authorID, commenterID)
|
||||
}
|
||||
|
||||
func (s *NotificationService) NotifyCommentReply(ctx context.Context, postID, recipientID, replierID string) {
|
||||
s.notifyComment(ctx, NotifCommentReply, postID, recipientID, replierID)
|
||||
}
|
||||
|
||||
func (s *NotificationService) notifyComment(ctx context.Context, kind, postID, recipientID, actorID string) {
|
||||
if recipientID == "" || actorID == "" || recipientID == actorID {
|
||||
return
|
||||
}
|
||||
off := s.disabledPrefs(ctx, recipientID)
|
||||
if err := s.notifs.CreateComment(ctx, recipientID, actorID, postID, kind); err != nil {
|
||||
s.logger.Warn("persist comment notification failed",
|
||||
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
|
||||
}
|
||||
actorUser, _ := s.users.FindByID(ctx, actorID)
|
||||
if !prefOff(off, kind, repository.ChannelInApp) {
|
||||
s.publishRealtime(ctx, recipientID, kind, actorUser)
|
||||
}
|
||||
|
||||
if s.push == nil || prefOff(off, kind, repository.ChannelPush) {
|
||||
return
|
||||
}
|
||||
who := "Someone"
|
||||
if actorUser != nil {
|
||||
who = actorDisplay(actorUser)
|
||||
}
|
||||
// Tapping it opens the post (via its author's handle — the actor isn't the
|
||||
// post owner on a reply). Falls back to the notification center.
|
||||
url := s.frontendURL + "/notifications"
|
||||
if h, err := s.notifs.PostAuthorHandle(ctx, postID); err == nil && h != "" {
|
||||
url = s.frontendURL + "/u/" + h + "/posts#post-" + postID
|
||||
}
|
||||
title, body := "New comment", who+" commented on your post"
|
||||
switch kind {
|
||||
case NotifCommentReply:
|
||||
title, body = "New reply", who+" replied to your comment"
|
||||
case NotifCommentMention:
|
||||
title, body = "You were mentioned", who+" mentioned you in a comment"
|
||||
}
|
||||
s.push.SendToUser(ctx, recipientID, PushPayload{
|
||||
Title: title,
|
||||
Body: body,
|
||||
URL: url,
|
||||
Icon: s.iconURL(),
|
||||
Tag: kind + ":" + postID,
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyCommentMentions tells each user named by `handles` that `actorID`
|
||||
// mentioned them in a comment on `postID`. Handles are resolved to users,
|
||||
// deduped, and the actor plus any `skip` ids (e.g. the post/parent author who
|
||||
// already got a comment/reply notification) are excluded so nobody is
|
||||
// double-pinged. Reuses the comment notification path (row + realtime + push).
|
||||
func (s *NotificationService) NotifyCommentMentions(ctx context.Context, postID, actorID string, handles []string, skip ...string) {
|
||||
if actorID == "" || len(handles) == 0 {
|
||||
return
|
||||
}
|
||||
skipSet := map[string]bool{actorID: true}
|
||||
for _, id := range skip {
|
||||
if id != "" {
|
||||
skipSet[id] = true
|
||||
}
|
||||
}
|
||||
for _, h := range handles {
|
||||
u, err := s.users.FindByHandle(ctx, h)
|
||||
if err != nil || u == nil || skipSet[u.ID] {
|
||||
continue
|
||||
}
|
||||
skipSet[u.ID] = true // dedupe repeats of the same mention
|
||||
s.notifyComment(ctx, NotifCommentMention, postID, u.ID, actorID)
|
||||
}
|
||||
}
|
||||
|
||||
// likeMessage renders the aggregated like text: "X liked your post" or
|
||||
// "X and N others liked your post".
|
||||
func likeMessage(who string, count int) string {
|
||||
if count <= 1 {
|
||||
return who + " liked your post"
|
||||
}
|
||||
others := count - 1
|
||||
noun := "others"
|
||||
if others == 1 {
|
||||
noun = "other"
|
||||
}
|
||||
return fmt.Sprintf("%s and %d %s liked your post", who, others, noun)
|
||||
}
|
||||
|
||||
// dispatch persists the notification and sends it over every channel the
|
||||
// recipient hasn't opted out of. All failures are logged, never propagated.
|
||||
func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) {
|
||||
off := s.disabledPrefs(ctx, recipientID)
|
||||
|
||||
// Always persist — the in-app pref filters display at read time, and
|
||||
// the row is the durable record either way.
|
||||
actor := actorID
|
||||
if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil {
|
||||
s.logger.Warn("persist notification failed",
|
||||
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
|
||||
// Persistence failed, but still try the other channels.
|
||||
}
|
||||
|
||||
// Look up the actor once — both the realtime push and the email use it.
|
||||
actorUser, aerr := s.users.FindByID(ctx, actorID)
|
||||
if aerr != nil {
|
||||
s.logger.Warn("notification actor lookup failed", zap.String("actor", actorID), zap.Error(aerr))
|
||||
}
|
||||
|
||||
// Realtime push (best-effort) — a lightweight nudge so the client can
|
||||
// bump the bell + toast immediately. Part of the in-app channel.
|
||||
if !prefOff(off, kind, repository.ChannelInApp) {
|
||||
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.
|
||||
if !prefOff(off, kind, repository.ChannelPush) {
|
||||
s.publishPush(ctx, recipientID, kind, actorUser)
|
||||
}
|
||||
|
||||
// Email (best-effort).
|
||||
if prefOff(off, kind, repository.ChannelEmail) {
|
||||
return
|
||||
}
|
||||
recipient, err := s.users.FindByID(ctx, recipientID)
|
||||
if err != nil {
|
||||
s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
|
||||
// No email on file (OAuth-only account that never added one) —
|
||||
// the persisted row + realtime push are the notification.
|
||||
return
|
||||
}
|
||||
if actorUser == nil {
|
||||
// Can't render the email body without the actor's name.
|
||||
return
|
||||
}
|
||||
|
||||
body, err := s.render(kind, recipient, actorUser)
|
||||
if err != nil {
|
||||
s.logger.Warn("render notification email failed", zap.String("kind", kind), zap.Error(err))
|
||||
return
|
||||
}
|
||||
subject := strings.Replace(subjectFmt, "%s", actorDisplay(actorUser), 1)
|
||||
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, body); err != nil {
|
||||
s.logger.Warn("notification email send failed",
|
||||
zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// publishRealtime pushes a lightweight notification event to the
|
||||
// recipient's Soketi channel. PUBLIC channel for now ("notifications.{id}")
|
||||
// — switch to a "private-" prefix once Soketi App auth is wired up. The
|
||||
// payload carries just enough for a toast; the client refetches the
|
||||
// authoritative unread count + list from REST.
|
||||
func (s *NotificationService) publishRealtime(ctx context.Context, recipientID, kind string, actor *model.User) {
|
||||
if s.rt == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]any{"kind": kind}
|
||||
if actor != nil {
|
||||
payload["actor_name"] = actorDisplay(actor)
|
||||
payload["actor_handle"] = actor.Handle
|
||||
payload["actor_avatar_url"] = actor.AvatarURL
|
||||
}
|
||||
if err := s.rt.Trigger(ctx, "notifications."+recipientID, "notification", payload); err != nil {
|
||||
s.logger.Warn("realtime notification publish failed",
|
||||
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// SendReminder delivers an ephemeral sādhana reminder across all live
|
||||
// channels — a websocket toast (no unread bump, not persisted), a Web Push,
|
||||
// and an email. Best-effort; nothing is persisted to the notification
|
||||
// center. `url` is where tapping the notification lands.
|
||||
func (s *NotificationService) SendReminder(ctx context.Context, userID, kind, title, body, url string) {
|
||||
off := s.disabledPrefs(ctx, userID)
|
||||
|
||||
// Realtime toast — a distinct "reminder" event so the client toasts
|
||||
// without bumping the unread badge (reminders have no DB row).
|
||||
if s.rt != nil && !prefOff(off, kind, repository.ChannelInApp) {
|
||||
_ = 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 && !prefOff(off, kind, repository.ChannelPush) {
|
||||
s.push.SendToUser(ctx, userID, PushPayload{Title: title, Body: body, URL: url, Icon: s.iconURL(), Tag: kind, RequireInteraction: true})
|
||||
}
|
||||
|
||||
// Email (only if there's an address on file).
|
||||
if prefOff(off, kind, repository.ChannelEmail) {
|
||||
return
|
||||
}
|
||||
recipient, err := s.users.FindByID(ctx, userID)
|
||||
if err != nil || recipient == nil || recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := reminderEmailTpl.Execute(&buf, map[string]any{
|
||||
"RecipientName": recipient.Name,
|
||||
"Message": body,
|
||||
"URL": url,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render reminder email failed", zap.String("kind", kind), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), title, buf.String()); err != nil {
|
||||
s.logger.Warn("reminder email send failed", zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// publishPush delivers a Web Push notification to the recipient's devices.
|
||||
// The body quotes the actor; tapping it opens their profile (where a pending
|
||||
// follow request can be accepted). Best-effort; nil sender is a no-op.
|
||||
func (s *NotificationService) publishPush(ctx context.Context, recipientID, kind string, actor *model.User) {
|
||||
if s.push == nil {
|
||||
return
|
||||
}
|
||||
who := "Someone"
|
||||
url := s.frontendURL + "/notifications"
|
||||
if actor != nil {
|
||||
who = actorDisplay(actor)
|
||||
url = s.frontendURL + "/u/" + actor.Handle
|
||||
}
|
||||
// Title must be meaningful, not the app name: iOS renders installed-PWA
|
||||
// notifications as "<title> from <app name>", so a generic "Prabhupāda
|
||||
// Says" title reads as "Prabhupāda Says from Prabhupāda Says".
|
||||
var title, body string
|
||||
switch kind {
|
||||
case NotifFollowRequest:
|
||||
title = "New Friend Request"
|
||||
body = who + " requested to follow you"
|
||||
case NotifFollowAccepted:
|
||||
title = "Friend Request Accepted"
|
||||
body = who + " accepted your follow request"
|
||||
default:
|
||||
title = "New notification"
|
||||
body = "You have a new notification"
|
||||
}
|
||||
s.push.SendToUser(ctx, recipientID, PushPayload{
|
||||
Title: title,
|
||||
Body: body,
|
||||
URL: url,
|
||||
Icon: s.iconURL(),
|
||||
Tag: kind,
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyAdminMessage delivers a free-text message from an admin to one user:
|
||||
// a persisted in-app notification (kind 'admin_message') plus best-effort
|
||||
// realtime, Web Push, and email. Admin messages bypass the per-kind opt-out
|
||||
// matrix (there's no category for them) — they're deliberate, low-volume, and
|
||||
// operationally important. Returns an error only if the row couldn't be
|
||||
// persisted; the three delivery channels are best-effort.
|
||||
func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientID, title, body string) error {
|
||||
if err := s.notifs.CreateAdminMessage(ctx, recipientID, body); err != nil {
|
||||
return err
|
||||
}
|
||||
url := s.frontendURL + "/notifications"
|
||||
|
||||
if s.rt != nil {
|
||||
if err := s.rt.Trigger(ctx, "notifications."+recipientID, "notification", map[string]any{
|
||||
"kind": "admin_message",
|
||||
"body": body,
|
||||
}); err != nil {
|
||||
s.logger.Warn("realtime admin message publish failed",
|
||||
zap.String("recipient", recipientID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if s.push != nil {
|
||||
pushTitle := strings.TrimSpace(title)
|
||||
if pushTitle == "" {
|
||||
pushTitle = "Prabhupāda Says"
|
||||
}
|
||||
s.push.SendToUser(ctx, recipientID, PushPayload{
|
||||
Title: pushTitle,
|
||||
Body: body,
|
||||
URL: url,
|
||||
Icon: s.iconURL(),
|
||||
Tag: "admin_message",
|
||||
})
|
||||
}
|
||||
|
||||
// Email — best-effort, only when the recipient has a verified-or-not address.
|
||||
recipient, _ := s.users.FindByID(ctx, recipientID)
|
||||
if recipient != nil && recipient.Email != nil && strings.TrimSpace(*recipient.Email) != "" {
|
||||
var buf bytes.Buffer
|
||||
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
|
||||
"RecipientName": recipient.Name,
|
||||
"Title": strings.TrimSpace(title),
|
||||
"Body": body,
|
||||
"URL": url,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render admin message email failed", zap.Error(err))
|
||||
} else {
|
||||
subject := strings.TrimSpace(title)
|
||||
if subject == "" {
|
||||
subject = "A message from Prabhupāda Says"
|
||||
}
|
||||
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, buf.String()); err != nil {
|
||||
s.logger.Warn("admin message email send failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// iconURL is the absolute URL of the notification icon (the app's square
|
||||
// icon), so it renders regardless of the client's base path.
|
||||
func (s *NotificationService) iconURL() string {
|
||||
return strings.TrimRight(s.frontendURL, "/") + "/icon-192.png"
|
||||
}
|
||||
|
||||
func (s *NotificationService) render(kind string, recipient, actor *model.User) (string, error) {
|
||||
data := map[string]any{
|
||||
"RecipientName": recipient.Name,
|
||||
"ActorName": actorDisplay(actor),
|
||||
"ActorHandle": actor.Handle,
|
||||
"ProfileURL": s.frontendURL + "/u/" + actor.Handle,
|
||||
}
|
||||
var tpl *template.Template
|
||||
switch kind {
|
||||
case NotifFollowRequest:
|
||||
tpl = followRequestEmailTpl
|
||||
case NotifFollowAccepted:
|
||||
tpl = followAcceptedEmailTpl
|
||||
default:
|
||||
tpl = followRequestEmailTpl
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// actorDisplay prefers the display name, falling back to @handle.
|
||||
func actorDisplay(u *model.User) string {
|
||||
if n := strings.TrimSpace(u.Name); n != "" {
|
||||
return n
|
||||
}
|
||||
return "@" + u.Handle
|
||||
}
|
||||
|
||||
// Vaishnava-styled, 17px base to match the feedback emails. Both quote
|
||||
// the actor and link to their profile (where the recipient can accept a
|
||||
// pending request).
|
||||
var followRequestEmailTpl = template.Must(template.New("followRequest").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||
|
||||
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
|
||||
has requested to follow you on Prabhupāda Says.</p>
|
||||
|
||||
<p>You can review and accept or decline the request from their profile:</p>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View request</a>
|
||||
</p>
|
||||
|
||||
<p style="margin-top:1.5em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
|
||||
var followAcceptedEmailTpl = template.Must(template.New("followAccepted").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||
|
||||
<p><strong>{{.ActorName}}</strong> (<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">@{{.ActorHandle}}</a>)
|
||||
has accepted your follow request. You're now following them on Prabhupāda Says.</p>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Visit profile</a>
|
||||
</p>
|
||||
|
||||
<p style="margin-top:1.5em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
|
||||
// Daily sādhana reminder. The Message is the recency-aware nudge; the CTA
|
||||
// opens the streak page.
|
||||
var reminderEmailTpl = template.Must(template.New("reminder").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||
|
||||
<p>{{.Message}}</p>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open your sādhana</a>
|
||||
</p>
|
||||
|
||||
<p style="margin-top:1.5em;font-size:14px;color:#7a6a55">You can turn these reminders off any time in Settings → Notifications.</p>
|
||||
|
||||
<p style="margin-top:1em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
|
||||
// Aggregated like email. Message is "X and N others liked your post"; the CTA
|
||||
// opens the author's post.
|
||||
var likeEmailTpl = template.Must(template.New("postLike").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||
|
||||
<p>{{.Message}}.</p>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View your post</a>
|
||||
</p>
|
||||
|
||||
<p style="margin-top:1.5em;font-size:14px;color:#7a6a55">You can turn off like emails any time in Settings → Notifications.</p>
|
||||
|
||||
<p style="margin-top:1em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
|
||||
// adminMessageEmailTpl renders an admin → user direct message. Body is
|
||||
// auto-escaped by html/template, so admin-entered text can't inject markup.
|
||||
var adminMessageEmailTpl = template.Must(template.New("adminMessage").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||
|
||||
{{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}}
|
||||
|
||||
<p style="white-space:pre-line">{{.Body}}</p>
|
||||
|
||||
<p style="margin:1.25em 0">
|
||||
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
||||
</p>
|
||||
|
||||
<p style="margin-top:1em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
@@ -0,0 +1,224 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const pushMaxAttempts = 3
|
||||
|
||||
// PushPayload is the JSON the service worker receives in its `push` event.
|
||||
type PushPayload struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
// RequireInteraction keeps the notification on screen until the user acts
|
||||
// on it (used for sādhana reminders, which shouldn't auto-dismiss).
|
||||
RequireInteraction bool `json:"require_interaction,omitempty"`
|
||||
}
|
||||
|
||||
// PushSender delivers Web Push messages via VAPID. Construct with
|
||||
// NewPushSender; it returns nil when VAPID isn't configured, and every method
|
||||
// is nil-safe so callers needn't branch.
|
||||
type PushSender struct {
|
||||
repo *repository.PushRepo
|
||||
pub string
|
||||
priv string
|
||||
subject string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPushSender returns nil if the VAPID keys are absent (push disabled).
|
||||
func NewPushSender(repo *repository.PushRepo, publicKey, privateKey, subject string, logger *zap.Logger) *PushSender {
|
||||
if publicKey == "" || privateKey == "" || repo == nil {
|
||||
return nil
|
||||
}
|
||||
// webpush-go prepends "mailto:" to any non-https subscriber, so a subject
|
||||
// already in "mailto:foo@bar" form would become "mailto:mailto:foo@bar" —
|
||||
// a malformed VAPID `sub` that Apple rejects with BadJwtToken (FCM is
|
||||
// lenient). Hand the library a bare address (or an https: URL) so it adds
|
||||
// exactly one prefix.
|
||||
subject = strings.TrimSpace(subject)
|
||||
if !strings.HasPrefix(subject, "https:") {
|
||||
subject = strings.TrimPrefix(subject, "mailto:")
|
||||
}
|
||||
return &PushSender{repo: repo, pub: publicKey, priv: privateKey, subject: subject, logger: logger}
|
||||
}
|
||||
|
||||
// PublicKey is the VAPID application server key the browser subscribes with.
|
||||
func (p *PushSender) PublicKey() string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.pub
|
||||
}
|
||||
|
||||
// Enabled reports whether push delivery is configured.
|
||||
func (p *PushSender) Enabled() bool { return p != nil }
|
||||
|
||||
// SendToUser pushes a payload to every subscription the user has, pruning any
|
||||
// endpoint the push service reports as gone (404/410). Best-effort: failures
|
||||
// are logged, never returned.
|
||||
func (p *PushSender) SendToUser(ctx context.Context, userID string, payload PushPayload) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
subs, err := p.repo.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
p.logger.Warn("push: list subscriptions failed", zap.String("user", userID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var sent, pruned, failed int
|
||||
for _, s := range subs {
|
||||
sub := &webpush.Subscription{
|
||||
Endpoint: s.Endpoint,
|
||||
Keys: webpush.Keys{P256dh: s.P256dh, Auth: s.Auth},
|
||||
}
|
||||
status, respBody, err := p.sendWithRetry(ctx, body, sub)
|
||||
if err != nil {
|
||||
failed++
|
||||
p.logger.Warn("push: send failed",
|
||||
zap.String("user", userID),
|
||||
zap.String("host", endpointHost(s.Endpoint)),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
// Subscription expired/gone — drop it so we stop trying.
|
||||
case status == http.StatusNotFound || status == http.StatusGone:
|
||||
pruned++
|
||||
if derr := p.repo.DeleteByEndpoint(ctx, s.Endpoint); derr != nil {
|
||||
p.logger.Warn("push: prune failed", zap.Error(derr))
|
||||
}
|
||||
case status >= 400:
|
||||
failed++
|
||||
// The response body carries the real reason (e.g. a VAPID
|
||||
// credential mismatch, malformed request) — log it.
|
||||
p.logger.Warn("push: non-2xx",
|
||||
zap.Int("status", status),
|
||||
zap.String("user", userID),
|
||||
zap.String("host", endpointHost(s.Endpoint)),
|
||||
zap.String("body", respBody))
|
||||
default:
|
||||
sent++
|
||||
p.logger.Debug("push: sent",
|
||||
zap.String("user", userID),
|
||||
zap.String("host", endpointHost(s.Endpoint)))
|
||||
}
|
||||
}
|
||||
p.logger.Debug("push: delivery summary",
|
||||
zap.String("user", userID),
|
||||
zap.Int("sent", sent), zap.Int("pruned", pruned), zap.Int("failed", failed))
|
||||
}
|
||||
|
||||
// sendWithRetry posts one notification, retrying on transient failures
|
||||
// (network error or 429/5xx) with a short backoff that honors Retry-After.
|
||||
// Returns the final HTTP status and, on a 4xx/5xx, a snippet of the response
|
||||
// body (the push service's error reason). Returns an error if every attempt
|
||||
// errored at the transport level.
|
||||
func (p *PushSender) sendWithRetry(ctx context.Context, body []byte, sub *webpush.Subscription) (int, string, error) {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= pushMaxAttempts; attempt++ {
|
||||
resp, err := webpush.SendNotification(body, sub, &webpush.Options{
|
||||
Subscriber: p.subject,
|
||||
VAPIDPublicKey: p.pub,
|
||||
VAPIDPrivateKey: p.priv,
|
||||
// Hold the message for up to 6h so a sleeping/offline device
|
||||
// still gets it on wake (60s would silently expire meanwhile).
|
||||
TTL: 6 * 60 * 60,
|
||||
Urgency: webpush.UrgencyNormal,
|
||||
})
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if attempt < pushMaxAttempts {
|
||||
sleepCtx(ctx, backoff(attempt))
|
||||
continue
|
||||
}
|
||||
return 0, "", err
|
||||
}
|
||||
status := resp.StatusCode
|
||||
retryAfter := resp.Header.Get("Retry-After")
|
||||
if isTransientStatus(status) && attempt < pushMaxAttempts {
|
||||
_ = resp.Body.Close()
|
||||
sleepCtx(ctx, retryDelay(retryAfter, attempt))
|
||||
continue
|
||||
}
|
||||
// Final attempt — capture the body on errors for diagnostics.
|
||||
var snippet string
|
||||
if status >= 400 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
snippet = strings.TrimSpace(string(b))
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return status, snippet, nil
|
||||
}
|
||||
return 0, "", lastErr
|
||||
}
|
||||
|
||||
// endpointHost extracts the host of a push endpoint for log context.
|
||||
func endpointHost(endpoint string) string {
|
||||
if u, err := url.Parse(endpoint); err == nil {
|
||||
return u.Host
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isTransientStatus(s int) bool {
|
||||
switch s {
|
||||
case http.StatusTooManyRequests, // 429
|
||||
http.StatusInternalServerError, // 500
|
||||
http.StatusBadGateway, // 502
|
||||
http.StatusServiceUnavailable, // 503
|
||||
http.StatusGatewayTimeout: // 504
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func backoff(attempt int) time.Duration {
|
||||
return time.Duration(attempt) * 500 * time.Millisecond
|
||||
}
|
||||
|
||||
// retryDelay prefers the push service's Retry-After (seconds), capped, else a
|
||||
// plain backoff.
|
||||
func retryDelay(retryAfter string, attempt int) time.Duration {
|
||||
if retryAfter != "" {
|
||||
if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 {
|
||||
d := time.Duration(secs) * time.Second
|
||||
if d > 30*time.Second {
|
||||
d = 30 * time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
}
|
||||
return backoff(attempt)
|
||||
}
|
||||
|
||||
// sleepCtx waits for d, returning early if the context is cancelled.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// Package service — ReminderService drives the daily sādhana reminders.
|
||||
//
|
||||
// A worker calls RunOnce on a fixed tick. For each reminder-enabled user it
|
||||
// computes the user's *local* clock time (from their IANA timezone) and, if
|
||||
// it falls in the send window for that reminder AND they haven't done the
|
||||
// discipline today AND they haven't already been reminded today, sends the
|
||||
// nudge through every channel (email + push + websocket toast).
|
||||
//
|
||||
// "Done today" and the once-per-day dedupe are bucketed by UTC calendar day,
|
||||
// matching the streak logic; only the *time of day* the reminder fires is
|
||||
// local. The recency-aware chant message is chosen from the user's most
|
||||
// recent completed chant day.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
// Embed the IANA tz database so time.LoadLocation works regardless of
|
||||
// whether the host image ships zoneinfo.
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ErrInvalidTimezone is returned when a timezone-set request carries a value
|
||||
// that isn't a loadable IANA zone.
|
||||
var ErrInvalidTimezone = errors.New("invalid timezone")
|
||||
|
||||
const (
|
||||
// reminderWindowMin must be >= the worker tick so no target time is
|
||||
// missed between ticks; the per-day dedupe prevents a double-send within
|
||||
// the overlap.
|
||||
reminderWindowMin = 20
|
||||
|
||||
// The evening "finish your rounds" reminder fires at 21:00 local. Kept
|
||||
// before the 21:30 quote reminder so the two don't collide in one tick.
|
||||
chantIncompleteTime = "21:00"
|
||||
|
||||
chantMsgStart = "Chant with Prabhupāda — start building your sādhana streak."
|
||||
chantMsgLapsed = "Just one hour of sādhana can save you from this duḥkhālayam aśāśvatam. Come and build your streak again."
|
||||
chantMsgYesterday = "Your sādhana was wonderful yesterday! Let's continue your sādhana today as well."
|
||||
quoteMsg = "Share a verse from the Vāṇī and complete today's sādhana."
|
||||
)
|
||||
|
||||
type ReminderService struct {
|
||||
repo *repository.ReminderRepo
|
||||
prefs *repository.NotificationPrefsRepo
|
||||
notif *NotificationService
|
||||
logger *zap.Logger
|
||||
sadhnaURL string
|
||||
}
|
||||
|
||||
func NewReminderService(repo *repository.ReminderRepo, prefs *repository.NotificationPrefsRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService {
|
||||
return &ReminderService{
|
||||
repo: repo,
|
||||
prefs: prefs,
|
||||
notif: notif,
|
||||
logger: logger,
|
||||
sadhnaURL: strings.TrimRight(frontendURL, "/") + "/sadhna-streak",
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled / SetEnabled back the legacy all-or-nothing reminder toggle — now a
|
||||
// shim over the per-kind/channel prefs (dual-writing the legacy users column)
|
||||
// so old clients and the new matrix stay consistent. "Enabled" means at least
|
||||
// one reminder kind × channel cell is still on.
|
||||
func (s *ReminderService) Enabled(ctx context.Context, userID string) (bool, error) {
|
||||
off, err := s.prefs.DisabledSet(ctx, userID)
|
||||
if err != nil {
|
||||
// Handler sanitizes to a generic 500 — log the cause here.
|
||||
s.logger.Error("reminder prefs: load failed", zap.String("user", userID), zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
for _, kind := range PrefCategories["sadhna_reminders"] {
|
||||
for _, ch := range PrefChannels {
|
||||
if _, disabled := off[repository.PrefKey(kind, ch)]; !disabled {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *ReminderService) SetEnabled(ctx context.Context, userID string, enabled bool) error {
|
||||
kinds := PrefCategories["sadhna_reminders"]
|
||||
var err error
|
||||
if enabled {
|
||||
err = s.prefs.Enable(ctx, userID, kinds, PrefChannels)
|
||||
} else {
|
||||
err = s.prefs.Disable(ctx, userID, kinds, PrefChannels)
|
||||
}
|
||||
if err == nil {
|
||||
err = s.prefs.SyncLegacyColumns(ctx, userID)
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Error("reminder prefs: write failed", zap.String("user", userID), zap.Error(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// SetTimezone validates the IANA zone before storing so a bad value can never
|
||||
// break the (timezone-using) candidate scan. Legacy aliases are canonicalized
|
||||
// first (browsers report e.g. "Asia/Calcutta"): Go resolves them but a
|
||||
// Postgres without the tzdata backward links rejects them in AT TIME ZONE.
|
||||
func (s *ReminderService) SetTimezone(ctx context.Context, userID, tz string) error {
|
||||
tz = canonicalTZ(strings.TrimSpace(tz))
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
if _, err := time.LoadLocation(tz); err != nil {
|
||||
return ErrInvalidTimezone
|
||||
}
|
||||
return s.repo.SetTimezone(ctx, userID, tz)
|
||||
}
|
||||
|
||||
// RunOnce evaluates every due candidate against `now` and fires the reminders
|
||||
// that are due. Errors are logged, never returned — a bad row shouldn't stop
|
||||
// the rest.
|
||||
func (s *ReminderService) RunOnce(ctx context.Context, now time.Time) {
|
||||
candidates, err := s.repo.DueCandidates(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("reminders: load candidates failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
utc := now.UTC()
|
||||
utcToday := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
for _, c := range candidates {
|
||||
loc, err := time.LoadLocation(c.TZ)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
local := now.In(loc)
|
||||
localMin := local.Hour()*60 + local.Minute()
|
||||
|
||||
// Chant reminder.
|
||||
if !c.ChantDoneToday && notRemindedToday(c.LastChantOn, utcToday) {
|
||||
if t := parseHHMM(c.ChantTime); t >= 0 && withinWindow(localMin, t) {
|
||||
s.notif.SendReminder(ctx, c.UserID, NotifChantReminder,
|
||||
"Time for your sādhana 🙏", chantMessage(c.LastChantDate, utcToday), s.sadhnaURL)
|
||||
if err := s.repo.MarkChantReminded(ctx, c.UserID, utcToday); err != nil {
|
||||
s.logger.Warn("reminders: mark chant failed", zap.String("user", c.UserID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Evening "incomplete chanting" reminder — the devotee started their
|
||||
// rounds today (RoundsToday > 0) but hasn't reached the target by
|
||||
// 21:00 local. Only nudges those who began, so non-starters get just
|
||||
// the morning reminder, not a second one. Independent of the morning
|
||||
// chant reminder (own time + own per-day dedupe).
|
||||
if c.RoundsToday > 0 && !c.ChantDoneToday && notRemindedToday(c.LastChantIncompleteOn, utcToday) {
|
||||
if t := parseHHMM(chantIncompleteTime); t >= 0 && withinWindow(localMin, t) {
|
||||
s.notif.SendReminder(ctx, c.UserID, NotifChantIncomplete,
|
||||
"Finish your japa 🙏", incompleteChantMessage(c.RoundsToday, c.TargetRounds), s.sadhnaURL)
|
||||
if err := s.repo.MarkChantIncompleteReminded(ctx, c.UserID, utcToday); err != nil {
|
||||
s.logger.Warn("reminders: mark chant-incomplete failed", zap.String("user", c.UserID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quote reminder.
|
||||
if !c.QuoteDoneToday && notRemindedToday(c.LastQuoteOn, utcToday) {
|
||||
if t := parseHHMM(c.QuoteTime); t >= 0 && withinWindow(localMin, t) {
|
||||
s.notif.SendReminder(ctx, c.UserID, NotifQuoteReminder,
|
||||
"Share a verse from the Vāṇī", quoteMsg, s.sadhnaURL)
|
||||
if err := s.repo.MarkQuoteReminded(ctx, c.UserID, utcToday); err != nil {
|
||||
s.logger.Warn("reminders: mark quote failed", zap.String("user", c.UserID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// chantMessage picks the recency-aware nudge from the most recent completed
|
||||
// chant day (both args are UTC midnight). The candidate is, by construction,
|
||||
// not done today, so the gap is >= 1 day.
|
||||
func chantMessage(lastChant *time.Time, utcToday time.Time) string {
|
||||
if lastChant == nil {
|
||||
return chantMsgStart
|
||||
}
|
||||
days := int(utcToday.Sub(*lastChant).Hours() / 24)
|
||||
switch {
|
||||
case days <= 1:
|
||||
return chantMsgYesterday // chanted yesterday
|
||||
case days <= 7:
|
||||
return chantMsgLapsed // within the last week, but not yesterday
|
||||
default:
|
||||
return chantMsgStart // nothing in over a week
|
||||
}
|
||||
}
|
||||
|
||||
// incompleteChantMessage nudges a devotee who began their rounds today to
|
||||
// finish them (only sent when rounds > 0).
|
||||
func incompleteChantMessage(rounds, target int) string {
|
||||
if target <= 0 {
|
||||
target = 16
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"You've chanted %d of %d rounds today — finish your japa before the day ends. 🙏",
|
||||
rounds, target)
|
||||
}
|
||||
|
||||
// notRemindedToday is true when no reminder of this kind has gone out on the
|
||||
// current UTC day.
|
||||
func notRemindedToday(last *time.Time, utcToday time.Time) bool {
|
||||
return last == nil || last.Before(utcToday)
|
||||
}
|
||||
|
||||
// withinWindow reports whether localMin falls in [target, target+window),
|
||||
// handling midnight wrap.
|
||||
func withinWindow(localMin, target int) bool {
|
||||
diff := ((localMin-target)%1440 + 1440) % 1440
|
||||
return diff < reminderWindowMin
|
||||
}
|
||||
|
||||
// parseHHMM turns "HH:MM" into minutes-of-day, or -1 if malformed.
|
||||
func parseHHMM(s string) int {
|
||||
parts := strings.SplitN(strings.TrimSpace(s), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return -1
|
||||
}
|
||||
h, err1 := strconv.Atoi(parts[0])
|
||||
m, err2 := strconv.Atoi(parts[1])
|
||||
if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
return -1
|
||||
}
|
||||
return h*60 + m
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
// Package service — SadhnaService is the sādhana-streak brain. It folds a
|
||||
// user's chanting logs (sadhna_logs) together with their quote posts
|
||||
// (posts table) into the single dashboard the /sadhna-streak page reads:
|
||||
// today's two disciplines, the trailing 7-day strip, the streak counters,
|
||||
// and the growing-plant state.
|
||||
//
|
||||
// A day is "complete" only when BOTH daily disciplines were done that UTC
|
||||
// calendar day: the target rounds were chanted AND at least one quote was
|
||||
// posted. The streak counts consecutive complete days; the plant's growth
|
||||
// is a 0–100 score that rises a little each complete day and falls faster
|
||||
// for each missed day, eventually decaying to 0 ("dead") — and revives as
|
||||
// soon as the practice resumes. The whole thing is recomputed from the
|
||||
// underlying rows on every read, so there's no derived state to keep in
|
||||
// sync and back-dated edits can never drift.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Tunables for the streak + plant. Kept together so the growth feel is
|
||||
// easy to adjust in one place.
|
||||
const (
|
||||
// defaultChantTarget is the fallback daily rounds when a user hasn't
|
||||
// set daily_chanting_rounds on their profile (Śrīla Prabhupāda's
|
||||
// minimum for initiated devotees).
|
||||
defaultChantTarget = 16
|
||||
|
||||
// growthMax is the score at which the plant is a full mature tree.
|
||||
growthMax = 100
|
||||
// growthPerDay is added for each complete day (≈25 perfect days from
|
||||
// seed to full tree).
|
||||
growthPerDay = 4
|
||||
// regressPerDay is subtracted for each missed day. Deliberately larger
|
||||
// than growthPerDay so neglect undoes progress faster than practice
|
||||
// builds it (a full tree withers to nothing in ~12 missed days).
|
||||
regressPerDay = 9
|
||||
|
||||
// streakWindowDays bounds how far back the recompute walks. Comfortably
|
||||
// longer than any growth/decay can persist, so the [0,100] clamp washes
|
||||
// out the unknown state before the window's start.
|
||||
streakWindowDays = 400
|
||||
|
||||
// vineMaxDays caps how many folds the creeper payload carries. The
|
||||
// client only renders as many as fit the pole anyway; CurrentStreak
|
||||
// still reports the true length.
|
||||
vineMaxDays = 60
|
||||
)
|
||||
|
||||
// rewardEveryDays is the streak cadence for gifts: one reward every 7
|
||||
// completed days, counted from the start of the current unbroken streak
|
||||
// (days 7, 14, 21, …). A broken streak restarts the count.
|
||||
const rewardEveryDays = 7
|
||||
|
||||
// SadhnaService computes streak/plant state and records chanting progress.
|
||||
type SadhnaService struct {
|
||||
logs *repository.SadhnaRepo
|
||||
posts *repository.PostRepo
|
||||
users *repository.UserRepo
|
||||
rewards *repository.RewardRepo
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewSadhnaService(
|
||||
logs *repository.SadhnaRepo,
|
||||
posts *repository.PostRepo,
|
||||
users *repository.UserRepo,
|
||||
rewards *repository.RewardRepo,
|
||||
logger *zap.Logger,
|
||||
) *SadhnaService {
|
||||
return &SadhnaService{logs: logs, posts: posts, users: users, rewards: rewards, logger: logger}
|
||||
}
|
||||
|
||||
const dayFmt = "2006-01-02"
|
||||
|
||||
// fallbackTZ is used when the user has no stored timezone — the sādhana day
|
||||
// boundary (and thus the nightly chant reset) defaults to India time.
|
||||
const fallbackTZ = "Asia/Kolkata"
|
||||
|
||||
// resolveTZ returns the user's IANA timezone name + loaded location, falling
|
||||
// back to Asia/Kolkata (then UTC) so the day boundary is always defined.
|
||||
//
|
||||
// The users.timezone column defaults to 'UTC' (migration 0028), so a bare
|
||||
// "UTC" means the browser never reported a real zone — i.e. we don't actually
|
||||
// know the user's local time. Per product intent we treat that as unknown and
|
||||
// fall back to India time, rather than literally bucketing their day at UTC
|
||||
// midnight (which mismatches the browser-local calendar on the client). A
|
||||
// genuine UTC user re-reports their real zone on next load.
|
||||
func (s *SadhnaService) resolveTZ(ctx context.Context, userID string) (string, *time.Location) {
|
||||
tz, _ := s.logs.UserTimezone(ctx, userID)
|
||||
// Canonicalize legacy aliases (e.g. stored "Asia/Calcutta") — the name
|
||||
// is fed into SQL AT TIME ZONE, which rejects aliases on a Postgres
|
||||
// without the tzdata backward links. Covers rows written before
|
||||
// SetTimezone started canonicalizing.
|
||||
tz = canonicalTZ(tz)
|
||||
if tz == "" || tz == "UTC" {
|
||||
tz = fallbackTZ
|
||||
}
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
tz = fallbackTZ
|
||||
if loc, err = time.LoadLocation(fallbackTZ); err != nil {
|
||||
return "UTC", time.UTC
|
||||
}
|
||||
}
|
||||
return tz, loc
|
||||
}
|
||||
|
||||
// todayIn returns the current calendar date in loc, anchored at UTC midnight
|
||||
// so it round-trips cleanly through Postgres DATE columns and dayFmt. This is
|
||||
// the anchor every day-bucket comparison uses — the day rolls over at the
|
||||
// user's local midnight.
|
||||
func todayIn(loc *time.Location) time.Time {
|
||||
now := time.Now().In(loc)
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// SaveChant records the user's chanting progress for today and returns the
|
||||
// freshly recomputed dashboard. Rounds outside [0,1008] are clamped.
|
||||
// chant_done is frozen as rounds >= the user's current target.
|
||||
func (s *SadhnaService) SaveChant(ctx context.Context, userID string, rounds int) (*model.SadhnaDashboard, error) {
|
||||
if rounds < 0 {
|
||||
rounds = 0
|
||||
}
|
||||
if rounds > 1008 {
|
||||
rounds = 1008
|
||||
}
|
||||
target := s.targetFor(ctx, userID)
|
||||
chantDone := rounds >= target
|
||||
|
||||
// Bucket against the user's local "today" so progress resets at their
|
||||
// local midnight (fallback Asia/Kolkata), not UTC midnight.
|
||||
_, loc := s.resolveTZ(ctx, userID)
|
||||
if _, err := s.logs.UpsertToday(ctx, userID, todayIn(loc), rounds, chantDone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Dashboard(ctx, userID)
|
||||
}
|
||||
|
||||
// Dashboard recomputes the user's full sādhana state from their chanting
|
||||
// logs and quote posts over the trailing window.
|
||||
func (s *SadhnaService) Dashboard(ctx context.Context, userID string) (*model.SadhnaDashboard, error) {
|
||||
target := s.targetFor(ctx, userID)
|
||||
tz, loc := s.resolveTZ(ctx, userID)
|
||||
today := todayIn(loc)
|
||||
since := today.AddDate(0, 0, -(streakWindowDays - 1))
|
||||
|
||||
logs, err := s.logs.ListSince(ctx, userID, since)
|
||||
if err != nil {
|
||||
s.logger.Error("sadhna dashboard: list logs failed",
|
||||
zap.String("user", userID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
// The quote half is non-critical: if it fails (e.g. an unrecognized
|
||||
// timezone in the AT TIME ZONE bucketing), log it and degrade to "no
|
||||
// quotes" rather than 500-ing the whole dashboard.
|
||||
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, since, tz)
|
||||
if err != nil {
|
||||
s.logger.Error("sadhna dashboard: post counts failed",
|
||||
zap.String("user", userID), zap.String("tz", tz), zap.Error(err))
|
||||
postsByDay = map[string]int{}
|
||||
}
|
||||
|
||||
// Index the chanting half by day.
|
||||
chantByDay := make(map[string]bool, len(logs))
|
||||
roundsByDay := make(map[string]int, len(logs))
|
||||
for _, l := range logs {
|
||||
key := l.LogDate.UTC().Format(dayFmt)
|
||||
chantByDay[key] = l.ChantDone
|
||||
roundsByDay[key] = l.RoundsCompleted
|
||||
}
|
||||
|
||||
// posted[day] = at least one quote that day; postsByDay[day] = how many.
|
||||
posted := func(day string) bool { return postsByDay[day] > 0 }
|
||||
// completed[day] = both disciplines done that day.
|
||||
completed := func(day string) bool { return chantByDay[day] && posted(day) }
|
||||
|
||||
todayKey := today.Format(dayFmt)
|
||||
|
||||
// --- Growth: forward-simulate the window, day by day. Today never
|
||||
// regresses the plant (the day isn't over yet) — it only adds growth
|
||||
// once complete. ---
|
||||
growth := 0
|
||||
for d := since; !d.After(today); d = d.AddDate(0, 0, 1) {
|
||||
key := d.Format(dayFmt)
|
||||
isToday := key == todayKey
|
||||
switch {
|
||||
case completed(key):
|
||||
growth += growthPerDay
|
||||
if growth > growthMax {
|
||||
growth = growthMax
|
||||
}
|
||||
case isToday:
|
||||
// in-progress day — leave growth as-is
|
||||
default:
|
||||
growth -= regressPerDay
|
||||
if growth < 0 {
|
||||
growth = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Streaks. Current streak ends at today if today is complete,
|
||||
// otherwise at yesterday (so an unfinished today doesn't read as a
|
||||
// broken streak mid-day). ---
|
||||
current := 0
|
||||
cursor := today
|
||||
if !completed(todayKey) {
|
||||
cursor = today.AddDate(0, 0, -1)
|
||||
}
|
||||
for !cursor.Before(since) && completed(cursor.Format(dayFmt)) {
|
||||
current++
|
||||
cursor = cursor.AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
// Longest run of complete days anywhere in the window.
|
||||
longest, run := 0, 0
|
||||
for d := since; !d.After(today); d = d.AddDate(0, 0, 1) {
|
||||
if completed(d.Format(dayFmt)) {
|
||||
run++
|
||||
if run > longest {
|
||||
longest = run
|
||||
}
|
||||
} else {
|
||||
run = 0
|
||||
}
|
||||
}
|
||||
if current > longest {
|
||||
longest = current
|
||||
}
|
||||
|
||||
// --- The trailing 7-day strip, oldest first. ---
|
||||
week := make([]model.SadhnaDay, 0, 7)
|
||||
for i := 6; i >= 0; i-- {
|
||||
d := today.AddDate(0, 0, -i)
|
||||
key := d.Format(dayFmt)
|
||||
week = append(week, model.SadhnaDay{
|
||||
Date: key,
|
||||
ChantDone: chantByDay[key],
|
||||
QuoteDone: posted(key),
|
||||
Completed: completed(key),
|
||||
})
|
||||
}
|
||||
|
||||
// --- The creeper: one fold per day of the current streak (oldest
|
||||
// first), plus a final in-progress fold for an unfinished today. The
|
||||
// streak's complete days end at today when today is complete, else at
|
||||
// yesterday; an unfinished today is appended as the growing tip. ---
|
||||
vine := make([]model.VineDay, 0, current+1)
|
||||
if current >= 1 {
|
||||
end := today
|
||||
if !completed(todayKey) {
|
||||
end = today.AddDate(0, 0, -1)
|
||||
}
|
||||
start := end.AddDate(0, 0, -(current - 1))
|
||||
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||
key := d.Format(dayFmt)
|
||||
vine = append(vine, model.VineDay{
|
||||
Date: key,
|
||||
Rounds: roundsByDay[key],
|
||||
Posts: postsByDay[key],
|
||||
Completed: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
if !completed(todayKey) {
|
||||
vine = append(vine, model.VineDay{
|
||||
Date: todayKey,
|
||||
Rounds: roundsByDay[todayKey],
|
||||
Posts: postsByDay[todayKey],
|
||||
InProgress: true,
|
||||
})
|
||||
}
|
||||
if len(vine) > vineMaxDays {
|
||||
vine = vine[len(vine)-vineMaxDays:]
|
||||
}
|
||||
|
||||
// --- Rewards: a gift every 7 streak-days. When today completes a
|
||||
// multiple-of-7 streak day, grant one (idempotently). Surface any
|
||||
// not-yet-celebrated grant + the projected date of the next gift. ---
|
||||
var pending *model.SadhnaRewardGrant
|
||||
var nextReward *string
|
||||
if s.rewards != nil {
|
||||
todayDone := completed(todayKey)
|
||||
if todayDone && current > 0 && current%rewardEveryDays == 0 {
|
||||
s.ensureGrant(ctx, userID, current)
|
||||
}
|
||||
pending, _ = s.rewards.PendingGrant(ctx, userID)
|
||||
// Only project a next-gift date when a streak is actually in
|
||||
// progress. With no live streak (current == 0) there's nothing to
|
||||
// "continue", so the calendar shows no gift marker until the devotee
|
||||
// completes a day (full rounds AND a quote).
|
||||
if current > 0 {
|
||||
nextReward = nextRewardDate(today, current, todayDone)
|
||||
}
|
||||
}
|
||||
|
||||
return &model.SadhnaDashboard{
|
||||
TargetRounds: target,
|
||||
RoundsCompleted: roundsByDay[todayKey],
|
||||
ChantDone: chantByDay[todayKey],
|
||||
QuoteDone: posted(todayKey),
|
||||
TodayCompleted: completed(todayKey),
|
||||
CurrentStreak: current,
|
||||
LongestStreak: longest,
|
||||
Growth: growth,
|
||||
Stage: stageFor(growth),
|
||||
Alive: growth > 0,
|
||||
Week: week,
|
||||
VineDays: vine,
|
||||
NextRewardDate: nextReward,
|
||||
PendingReward: pending,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureGrant idempotently awards a gift for the given streak milestone,
|
||||
// picking a reward the user hasn't received yet. Once every reward has been
|
||||
// collected the pool resets (avoiding an immediate back-to-back repeat) so
|
||||
// the gifts cycle. Best-effort: any error leaves the milestone ungranted,
|
||||
// to be retried on the next recompute.
|
||||
func (s *SadhnaService) ensureGrant(ctx context.Context, userID string, milestone int) {
|
||||
has, err := s.rewards.HasMilestone(ctx, userID, milestone)
|
||||
if err != nil || has {
|
||||
return
|
||||
}
|
||||
pool, err := s.rewards.ListGiftable(ctx)
|
||||
if err != nil || len(pool) == 0 {
|
||||
return
|
||||
}
|
||||
granted, err := s.rewards.GrantedRewardIDs(ctx, userID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer rewards not yet received.
|
||||
cand := make([]model.SadhnaReward, 0, len(pool))
|
||||
for _, r := range pool {
|
||||
if !granted[r.ID] {
|
||||
cand = append(cand, r)
|
||||
}
|
||||
}
|
||||
// All collected → cycle, excluding the most recent to avoid a repeat.
|
||||
if len(cand) == 0 {
|
||||
last, _ := s.rewards.LastGrantedRewardID(ctx, userID)
|
||||
for _, r := range pool {
|
||||
if r.ID != last {
|
||||
cand = append(cand, r)
|
||||
}
|
||||
}
|
||||
if len(cand) == 0 {
|
||||
cand = pool
|
||||
}
|
||||
}
|
||||
|
||||
pick := cand[rand.Intn(len(cand))]
|
||||
if err := s.rewards.CreateGrant(ctx, userID, pick.ID, milestone); err != nil {
|
||||
s.logger.Warn("sadhna: could not grant reward", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// nextRewardDate projects the UTC date the next gift lands if the streak
|
||||
// continues unbroken — the date the streak count next reaches a multiple of
|
||||
// rewardEveryDays. An unfinished today is assumed to be completed.
|
||||
func nextRewardDate(today time.Time, current int, todayCompleted bool) *string {
|
||||
next := (current/rewardEveryDays + 1) * rewardEveryDays // first multiple > current
|
||||
days := next - current
|
||||
if !todayCompleted {
|
||||
// today isn't counted yet; completing it is the first of the
|
||||
// remaining days, so it lands one day sooner.
|
||||
days--
|
||||
}
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
d := today.AddDate(0, 0, days).Format(dayFmt)
|
||||
return &d
|
||||
}
|
||||
|
||||
// Rewards returns the user's collected gifts (newest first) and the size of
|
||||
// the giftable catalog (for "N of M" framing on the client).
|
||||
func (s *SadhnaService) Rewards(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, int, error) {
|
||||
grants, err := s.rewards.GrantsByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
pool, err := s.rewards.ListGiftable(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return grants, len(pool), nil
|
||||
}
|
||||
|
||||
// AckRewards clears the user's pending celebration(s) after the client has
|
||||
// shown the confetti.
|
||||
func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error {
|
||||
return s.rewards.MarkCelebrated(ctx, userID)
|
||||
}
|
||||
|
||||
// History returns the per-day sādhana state for every UTC calendar day in
|
||||
// the inclusive range [from, to] — what the calendar needs to render an
|
||||
// arbitrary week or month. Days with no activity (including future days)
|
||||
// come back as all-false. The caller is expected to bound the range.
|
||||
func (s *SadhnaService) History(ctx context.Context, userID string, from, to time.Time) ([]model.SadhnaDay, error) {
|
||||
tz, _ := s.resolveTZ(ctx, userID)
|
||||
logs, err := s.logs.ListSince(ctx, userID, from)
|
||||
if err != nil {
|
||||
s.logger.Error("sadhna history: list logs failed",
|
||||
zap.String("user", userID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, from, tz)
|
||||
if err != nil {
|
||||
s.logger.Error("sadhna history: post counts failed",
|
||||
zap.String("user", userID), zap.String("tz", tz), zap.Error(err))
|
||||
postsByDay = map[string]int{}
|
||||
}
|
||||
|
||||
chantByDay := make(map[string]bool, len(logs))
|
||||
for _, l := range logs {
|
||||
chantByDay[l.LogDate.UTC().Format(dayFmt)] = l.ChantDone
|
||||
}
|
||||
|
||||
out := make([]model.SadhnaDay, 0, 42)
|
||||
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
||||
key := d.Format(dayFmt)
|
||||
quote := postsByDay[key] > 0
|
||||
out = append(out, model.SadhnaDay{
|
||||
Date: key,
|
||||
ChantDone: chantByDay[key],
|
||||
QuoteDone: quote,
|
||||
Completed: chantByDay[key] && quote,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// targetFor returns the user's daily rounds goal, falling back to the
|
||||
// default when unset/out of range or when the user can't be loaded (the
|
||||
// dashboard should still render).
|
||||
func (s *SadhnaService) targetFor(ctx context.Context, userID string) int {
|
||||
u, err := s.users.FindByID(ctx, userID)
|
||||
if err != nil || u == nil || u.DailyChantingRounds == nil {
|
||||
return defaultChantTarget
|
||||
}
|
||||
if r := *u.DailyChantingRounds; r >= 1 && r <= 1008 {
|
||||
return r
|
||||
}
|
||||
return defaultChantTarget
|
||||
}
|
||||
|
||||
// stageFor maps a growth score to the plant's visual stage. The client
|
||||
// renders the matching artwork and interpolates within the stage by the
|
||||
// raw growth value.
|
||||
func stageFor(growth int) string {
|
||||
switch {
|
||||
case growth <= 0:
|
||||
return "dead"
|
||||
case growth <= 15:
|
||||
return "sprout"
|
||||
case growth <= 35:
|
||||
return "seedling"
|
||||
case growth <= 60:
|
||||
return "sapling"
|
||||
case growth <= 85:
|
||||
return "young"
|
||||
default:
|
||||
return "mature"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
// Package service — SocialGraphService is the follow / block / report
|
||||
// brain. It sits over FollowRepo + BlockRepo + ReportRepo, resolves the
|
||||
// target by handle, enforces the rules (no self-follow, no following
|
||||
// across a block), keeps the request-based follow lifecycle honest, and
|
||||
// routes every notable event through the NotificationService.
|
||||
//
|
||||
// Follows are request-based end to end: RequestFollow only ever creates a
|
||||
// PENDING edge; the target turns it into a real follow via AcceptFollow.
|
||||
// Every mutating method returns the freshly-recomputed RelationshipResult
|
||||
// (the viewer's state toward the target + the target's follower/following
|
||||
// counts) so the frontend can update its buttons and stat line from one
|
||||
// response.
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Sentinels specific to the social graph (ErrForbidden / ErrNotFound are
|
||||
// shared from social_service.go in this package).
|
||||
var (
|
||||
// ErrBlocked — the action can't proceed because one of the two users
|
||||
// has blocked the other.
|
||||
ErrBlocked = errors.New("blocked")
|
||||
// ErrSelfRelation — you can't follow / block / report yourself.
|
||||
ErrSelfRelation = errors.New("cannot perform this action on yourself")
|
||||
)
|
||||
|
||||
// reportCategories is the allow-list the Report flow validates against —
|
||||
// mirrored by the frontend's report dialog dropdown.
|
||||
var reportCategories = map[string]bool{
|
||||
"spam": true,
|
||||
"harassment": true,
|
||||
"impersonation": true,
|
||||
"inappropriate": true,
|
||||
"other": true,
|
||||
}
|
||||
|
||||
// postReportCategories is the allow-list for reporting a post — mirrored
|
||||
// by the frontend's post report dialog.
|
||||
var postReportCategories = map[string]bool{
|
||||
"spam": true,
|
||||
"harassment": true,
|
||||
"misinformation": true,
|
||||
"inappropriate": true,
|
||||
"other": true,
|
||||
}
|
||||
|
||||
// RelationshipResult is the payload every relationship mutation returns:
|
||||
// the viewer's relationship to the target plus the target's live counts.
|
||||
type RelationshipResult struct {
|
||||
Relationship *model.RelationshipState `json:"relationship"`
|
||||
FollowerCount int `json:"follower_count"`
|
||||
FollowingCount int `json:"following_count"`
|
||||
}
|
||||
|
||||
type SocialGraphService struct {
|
||||
follows *repository.FollowRepo
|
||||
blocks *repository.BlockRepo
|
||||
reports *repository.ReportRepo
|
||||
posts *repository.PostRepo
|
||||
users *repository.UserRepo
|
||||
notif *NotificationService
|
||||
mail mailer.Mailer
|
||||
admins []string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewSocialGraphService(
|
||||
follows *repository.FollowRepo,
|
||||
blocks *repository.BlockRepo,
|
||||
reports *repository.ReportRepo,
|
||||
posts *repository.PostRepo,
|
||||
users *repository.UserRepo,
|
||||
notif *NotificationService,
|
||||
mail mailer.Mailer,
|
||||
admins []string,
|
||||
logger *zap.Logger,
|
||||
) *SocialGraphService {
|
||||
return &SocialGraphService{
|
||||
follows: follows, blocks: blocks, reports: reports, posts: posts,
|
||||
users: users, notif: notif, mail: mail, admins: admins, logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// resolve looks up the target by handle and rejects self-targeting.
|
||||
func (s *SocialGraphService) resolve(ctx context.Context, viewerID, handle string) (*model.User, error) {
|
||||
t, err := s.users.FindByHandle(ctx, handle)
|
||||
if err != nil {
|
||||
return nil, err // ErrUserNotFound → 404 via mapSocialErr
|
||||
}
|
||||
if t.ID == viewerID {
|
||||
return nil, ErrSelfRelation
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// RequestFollow inserts a pending follow edge viewer→target and notifies
|
||||
// the target. Refuses if either party has blocked the other. Idempotent:
|
||||
// re-requesting an existing edge doesn't re-notify.
|
||||
func (s *SocialGraphService) RequestFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
viewerBlocked, targetBlocked, err := s.blocks.BlockState(ctx, viewerID, t.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if viewerBlocked || targetBlocked {
|
||||
return nil, ErrBlocked
|
||||
}
|
||||
created, err := s.follows.Request(ctx, viewerID, t.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if created {
|
||||
s.notif.NotifyFollowRequest(ctx, t.ID, viewerID)
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// Unfollow removes the viewer→target edge — covers both "stop following"
|
||||
// (accepted) and "cancel my pending request". Idempotent.
|
||||
func (s *SocialGraphService) Unfollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.follows.Delete(ctx, viewerID, t.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// AcceptFollow accepts the target's pending request to follow the viewer
|
||||
// (edge target→viewer). On the pending→accepted transition the target is
|
||||
// notified that their request went through. `handle` is the requester.
|
||||
func (s *SocialGraphService) AcceptFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accepted, err := s.follows.Accept(ctx, t.ID, viewerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if accepted {
|
||||
s.notif.NotifyFollowAccepted(ctx, t.ID, viewerID)
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// RejectFollow declines the target's pending request to follow the viewer
|
||||
// by deleting the target→viewer edge. Idempotent; no notification (a
|
||||
// silent decline is kinder than an explicit rejection email).
|
||||
func (s *SocialGraphService) RejectFollow(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.follows.Delete(ctx, t.ID, viewerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// RemoveFollower drops the target from the viewer's followers by deleting
|
||||
// the target→viewer edge. Mechanically the same as RejectFollow but
|
||||
// distinct in intent (an accepted follower is being removed). Idempotent.
|
||||
func (s *SocialGraphService) RemoveFollower(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.follows.Delete(ctx, t.ID, viewerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// Block records viewer→target and severs any follow edges both ways.
|
||||
func (s *SocialGraphService) Block(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.blocks.Block(ctx, viewerID, t.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// Unblock removes the viewer→target block.
|
||||
func (s *SocialGraphService) Unblock(ctx context.Context, viewerID, handle string) (*RelationshipResult, error) {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.blocks.Unblock(ctx, viewerID, t.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.result(ctx, viewerID, t)
|
||||
}
|
||||
|
||||
// Report files a report against the target and emails the admins. The
|
||||
// category must be in the allow-list and the reason non-empty.
|
||||
func (s *SocialGraphService) Report(ctx context.Context, viewerID, handle, category, reason string) error {
|
||||
t, err := s.resolve(ctx, viewerID, handle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
if !reportCategories[category] {
|
||||
return errors.New("invalid report category")
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return errors.New("reason is required")
|
||||
}
|
||||
if len(reason) > 2000 {
|
||||
return errors.New("reason too long (max 2000)")
|
||||
}
|
||||
if _, err := s.reports.Create(ctx, viewerID, t.ID, category, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
// Email the admins — fire-and-forget; the row is already persisted.
|
||||
reporter, rerr := s.users.FindByID(ctx, viewerID)
|
||||
if rerr != nil {
|
||||
s.logger.Warn("report saved but reporter lookup failed; emailing without reporter info",
|
||||
zap.String("reporter", viewerID), zap.Error(rerr))
|
||||
}
|
||||
s.notifyAdminsOfReport(reporter, t, category, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReportPost files a report against a specific post and emails the admins.
|
||||
// The category must be in the post allow-list; the reason is optional.
|
||||
func (s *SocialGraphService) ReportPost(ctx context.Context, reporterID, postID, category, reason string) error {
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
if !postReportCategories[category] {
|
||||
return errors.New("invalid report category")
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if len(reason) > 2000 {
|
||||
return errors.New("reason too long (max 2000)")
|
||||
}
|
||||
post, err := s.posts.FindByID(ctx, postID)
|
||||
if err != nil {
|
||||
return err // ErrPostNotFound → 404 via mapSocialErr
|
||||
}
|
||||
if _, err := s.reports.CreatePostReport(ctx, reporterID, post.ID, category, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
reporter, rerr := s.users.FindByID(ctx, reporterID)
|
||||
if rerr != nil {
|
||||
s.logger.Warn("post report saved but reporter lookup failed; emailing without reporter info",
|
||||
zap.String("reporter", reporterID), zap.Error(rerr))
|
||||
}
|
||||
s.notifyAdminsOfPostReport(reporter, post, category, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Relationship computes the viewer's relationship toward targetID.
|
||||
func (s *SocialGraphService) Relationship(ctx context.Context, viewerID, targetID string) (*model.RelationshipState, error) {
|
||||
out := &model.RelationshipState{}
|
||||
outStatus, err := s.follows.Status(ctx, viewerID, targetID) // viewer → target
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inStatus, err := s.follows.Status(ctx, targetID, viewerID) // target → viewer
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Following = outStatus == "accepted"
|
||||
out.FollowRequested = outStatus == "pending"
|
||||
out.FollowsYou = inStatus == "accepted"
|
||||
out.RequestsYou = inStatus == "pending"
|
||||
|
||||
viewerBlocked, blockedByTarget, err := s.blocks.BlockState(ctx, viewerID, targetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Blocked = viewerBlocked
|
||||
out.BlockedBy = blockedByTarget
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsBlockedBetween reports whether a block exists in EITHER direction
|
||||
// between two users — viewer→target or target→viewer. Content visibility
|
||||
// is gated on this (a block either way hides the target's profile feed,
|
||||
// tagged posts, and follower lists from the viewer), mirroring the
|
||||
// "viewerBlocked || targetBlocked" rule RequestFollow enforces. Returns
|
||||
// false for anonymous viewers (empty id) and for the self case.
|
||||
func (s *SocialGraphService) IsBlockedBetween(ctx context.Context, a, b string) (bool, error) {
|
||||
if a == "" || b == "" || a == b {
|
||||
return false, nil
|
||||
}
|
||||
aBlockedB, bBlockedA, err := s.blocks.BlockState(ctx, a, b)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return aBlockedB || bBlockedA, nil
|
||||
}
|
||||
|
||||
// Counts returns the accepted-follower and accepted-following totals for
|
||||
// a user.
|
||||
func (s *SocialGraphService) Counts(ctx context.Context, userID string) (followers, following int, err error) {
|
||||
followers, err = s.follows.CountFollowers(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
following, err = s.follows.CountFollowing(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return followers, following, nil
|
||||
}
|
||||
|
||||
// Followers / Following resolve the handle and return one page of the
|
||||
// profile's accepted followers (or followings), optionally filtered by a
|
||||
// name/handle search `q`, plus the total matching count for pagination.
|
||||
func (s *SocialGraphService) Followers(ctx context.Context, handle, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||
u, err := s.users.FindByHandle(ctx, handle)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return s.follows.ListFollowers(ctx, u.ID, q, limit, offset)
|
||||
}
|
||||
|
||||
// NewUsers returns up to `limit` accounts that joined in the last 24h for the
|
||||
// feed's "new on the platform" discovery strip — excluding the viewer, anyone
|
||||
// they already have a follow edge with, and blocked pairs.
|
||||
func (s *SocialGraphService) NewUsers(ctx context.Context, viewerID string, limit int) ([]model.UserSummary, error) {
|
||||
return s.users.RecentlyJoined(ctx, viewerID, time.Now().Add(-24*time.Hour), limit)
|
||||
}
|
||||
|
||||
func (s *SocialGraphService) Following(ctx context.Context, handle, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||
u, err := s.users.FindByHandle(ctx, handle)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return s.follows.ListFollowing(ctx, u.ID, q, limit, offset)
|
||||
}
|
||||
|
||||
// ProfileExtras is the read used by the public-profile endpoint: the
|
||||
// target's follower/following counts plus, when a non-owner viewer is
|
||||
// signed in, their relationship to the target. rel is nil for anonymous
|
||||
// viewers and for the owner viewing their own profile.
|
||||
func (s *SocialGraphService) ProfileExtras(ctx context.Context, viewerID string, target *model.User) (followers, following int, rel *model.RelationshipState, err error) {
|
||||
followers, following, err = s.Counts(ctx, target.ID)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
if viewerID == "" || viewerID == target.ID {
|
||||
return followers, following, nil, nil
|
||||
}
|
||||
rel, err = s.Relationship(ctx, viewerID, target.ID)
|
||||
if err != nil {
|
||||
return 0, 0, nil, err
|
||||
}
|
||||
return followers, following, rel, nil
|
||||
}
|
||||
|
||||
// result recomputes the viewer→target relationship and the target's
|
||||
// counts after a mutation.
|
||||
func (s *SocialGraphService) result(ctx context.Context, viewerID string, target *model.User) (*RelationshipResult, error) {
|
||||
rel, err := s.Relationship(ctx, viewerID, target.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
followers, following, err := s.Counts(ctx, target.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RelationshipResult{
|
||||
Relationship: rel,
|
||||
FollowerCount: followers,
|
||||
FollowingCount: following,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SocialGraphService) notifyAdminsOfReport(reporter, reported *model.User, category, reason string) {
|
||||
if len(s.admins) == 0 {
|
||||
s.logger.Info("user report received but no admin recipients configured",
|
||||
zap.String("reported", reported.ID))
|
||||
return
|
||||
}
|
||||
from := "anonymous"
|
||||
if reporter != nil {
|
||||
from = fmt.Sprintf("%s (@%s)", reporter.Name, reporter.Handle)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := userReportEmailTpl.Execute(&buf, map[string]any{
|
||||
"ReporterFrom": from,
|
||||
"ReportedName": reported.Name,
|
||||
"ReportedHandle": reported.Handle,
|
||||
"Category": category,
|
||||
"Reason": reason,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render user-report email failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
subject := fmt.Sprintf("[Report · %s] @%s", category, reported.Handle)
|
||||
body := buf.String()
|
||||
for _, to := range s.admins {
|
||||
if err := s.mail.Send(to, subject, body); err != nil {
|
||||
s.logger.Warn("user-report email send failed", zap.String("to", to), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var userReportEmailTpl = template.Must(template.New("userReport").Parse(`<p><strong>A user has been reported</strong></p>
|
||||
<p><strong>Reported:</strong> {{.ReportedName}} (@{{.ReportedHandle}})<br>
|
||||
<strong>Reported by:</strong> {{.ReporterFrom}}<br>
|
||||
<strong>Category:</strong> {{.Category}}</p>
|
||||
<p><strong>Reason:</strong></p>
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Reason}}</pre>`))
|
||||
|
||||
func (s *SocialGraphService) notifyAdminsOfPostReport(reporter *model.User, post *model.Post, category, reason string) {
|
||||
if len(s.admins) == 0 {
|
||||
s.logger.Info("post report received but no admin recipients configured",
|
||||
zap.String("post", post.ID))
|
||||
return
|
||||
}
|
||||
from := "anonymous"
|
||||
if reporter != nil {
|
||||
from = fmt.Sprintf("%s (@%s)", reporter.Name, reporter.Handle)
|
||||
}
|
||||
author := post.AuthorName
|
||||
if post.AuthorHandle != "" {
|
||||
author = fmt.Sprintf("%s (@%s)", post.AuthorName, post.AuthorHandle)
|
||||
}
|
||||
if author == "" {
|
||||
author = "unknown"
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := postReportEmailTpl.Execute(&buf, map[string]any{
|
||||
"PostID": post.ID,
|
||||
"Author": author,
|
||||
"ReporterFrom": from,
|
||||
"Category": category,
|
||||
"Reason": reason,
|
||||
"Caption": post.Caption,
|
||||
"SourceURL": post.SourceURL,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render post-report email failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
subject := fmt.Sprintf("[Post report · %s] %s", category, summarize(post.Caption, 50))
|
||||
body := buf.String()
|
||||
for _, to := range s.admins {
|
||||
if err := s.mail.Send(to, subject, body); err != nil {
|
||||
s.logger.Warn("post-report email send failed", zap.String("to", to), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var postReportEmailTpl = template.Must(template.New("postReport").Parse(`<p><strong>A post has been reported</strong></p>
|
||||
<p><strong>Post ID:</strong> {{.PostID}}<br>
|
||||
<strong>Author:</strong> {{.Author}}<br>
|
||||
<strong>Reported by:</strong> {{.ReporterFrom}}<br>
|
||||
<strong>Category:</strong> {{.Category}}</p>
|
||||
{{if .Reason}}<p><strong>Reason:</strong></p>
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Reason}}</pre>{{end}}
|
||||
<p><strong>Caption:</strong></p>
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;background:#fafafa;padding:0.75em 1em;border-radius:6px;border:1px solid #eee">{{.Caption}}</pre>
|
||||
<p style="color:#999;font-size:12px"><a href="{{.SourceURL}}">{{.SourceURL}}</a></p>`))
|
||||
@@ -33,17 +33,26 @@ var (
|
||||
type PostService struct {
|
||||
posts *repository.PostRepo
|
||||
r2 *repository.R2
|
||||
likes *repository.LikeRepo
|
||||
mentions *repository.MentionRepo
|
||||
comments *repository.CommentRepo
|
||||
feedSeen *repository.FeedRepo // per-user "already served" set for the home feed
|
||||
notif *NotificationService // optional; set via SetNotifier
|
||||
}
|
||||
|
||||
func NewPostService(posts *repository.PostRepo, r2 *repository.R2) *PostService {
|
||||
return &PostService{posts: posts, r2: r2}
|
||||
func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo, mentions *repository.MentionRepo, comments *repository.CommentRepo, feedSeen *repository.FeedRepo) *PostService {
|
||||
return &PostService{posts: posts, r2: r2, likes: likes, mentions: mentions, comments: comments, feedSeen: feedSeen}
|
||||
}
|
||||
|
||||
// SetNotifier wires the notification seam after construction (the
|
||||
// NotificationService is built later in server wiring than PostService).
|
||||
func (s *PostService) SetNotifier(n *NotificationService) { s.notif = n }
|
||||
|
||||
// Create persists a new post. The caption is treated as opaque — we trust
|
||||
// the frontend to send the exact text the user selected. Source URL must be
|
||||
// a same-origin path (e.g. /books/bg/1/1?hl=…) — we strip the host if one
|
||||
// was provided so links can't be hijacked.
|
||||
func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
|
||||
func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs, mentionUserIDs []string) (*model.Post, error) {
|
||||
caption = strings.TrimSpace(caption)
|
||||
if caption == "" {
|
||||
return nil, errors.New("caption is required")
|
||||
@@ -58,12 +67,106 @@ func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, so
|
||||
if len(imageURLs) > 5 {
|
||||
return nil, errors.New("a post can hold up to 5 images")
|
||||
}
|
||||
return s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs)
|
||||
p, err := s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Tag mentioned users (best-effort, additive — a bad id never fails the
|
||||
// post itself). Reflect the count on the returned post, then notify the
|
||||
// tagged people (fire-and-forget on a detached context).
|
||||
if len(mentionUserIDs) > 0 {
|
||||
_ = s.mentions.AddMany(ctx, p.ID, mentionUserIDs)
|
||||
_ = s.mentions.HydrateCounts(ctx, []*model.Post{p})
|
||||
go s.notifyMentions(context.Background(), p.ID, userID, mentionUserIDs)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Get returns one post (public).
|
||||
func (s *PostService) Get(ctx context.Context, id string) (*model.Post, error) {
|
||||
return s.posts.FindByID(ctx, id)
|
||||
// Get returns one post (public). `viewerID` is "" for anonymous callers;
|
||||
// when set, the returned post carries the viewer's reshare state so the
|
||||
// frontend can render the right action button without a second request.
|
||||
func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post, error) {
|
||||
p, err := s.posts.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.hydrateReshareStates(ctx, viewerID, []*model.Post{p}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.likes.Hydrate(ctx, viewerID, []*model.Post{p}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.mentions.HydrateCounts(ctx, []*model.Post{p}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.comments.Hydrate(ctx, viewerID, []*model.Post{p}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Reshare materialises a reshare as a brand-new posts row owned by the
|
||||
// caller, with reshared_from_post_id pointing to the canonical original.
|
||||
// Idempotent — calling it twice returns the same reshare row. Resharing
|
||||
// one's own post (canonical or chained) is rejected at the repo layer.
|
||||
//
|
||||
// Returns the new post so the frontend can drop it into local state
|
||||
// without a round-trip.
|
||||
func (s *PostService) Reshare(ctx context.Context, postID, callerID string) (*model.Post, error) {
|
||||
return s.posts.Reshare(ctx, postID, callerID)
|
||||
}
|
||||
|
||||
// Unreshare removes the caller's reshare of a canonical post by deleting
|
||||
// their own reshare posts row. Idempotent — no-ops when nothing to delete.
|
||||
func (s *PostService) Unreshare(ctx context.Context, postID, callerID string) error {
|
||||
return s.posts.Unreshare(ctx, postID, callerID)
|
||||
}
|
||||
|
||||
// hydrateReshareStates fills in ViewerHasReshared on each post via one
|
||||
// batched lookup. Skips the query when the viewer is anonymous (the
|
||||
// answer is uniformly false) or the slice is empty.
|
||||
func (s *PostService) hydrateReshareStates(ctx context.Context, viewerID string, posts []*model.Post) error {
|
||||
if viewerID == "" || len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
states, err := s.posts.ReshareStatesFor(ctx, viewerID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range posts {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if states[p.ID] {
|
||||
p.ViewerHasReshared = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hydrateReshareStatesSlice is the value-slice variant of
|
||||
// hydrateReshareStates. Repository list methods return []model.Post
|
||||
// (not pointers) — we wrap each element so the helper can mutate in
|
||||
// place via a stable pointer.
|
||||
func (s *PostService) hydrateReshareStatesSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
||||
if len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
ptrs := make([]*model.Post, len(posts))
|
||||
for i := range posts {
|
||||
ptrs[i] = &posts[i]
|
||||
}
|
||||
return s.hydrateReshareStates(ctx, viewerID, ptrs)
|
||||
}
|
||||
|
||||
// Delete checks ownership and removes the post.
|
||||
@@ -71,9 +174,15 @@ func (s *PostService) Delete(ctx context.Context, postID, callerID string) error
|
||||
return s.posts.Delete(ctx, postID, callerID)
|
||||
}
|
||||
|
||||
// ReplaceImages swaps the image set on a post. The caller must own the
|
||||
// post. Returns the fresh post payload so the client can reconcile its
|
||||
// local state without a separate fetch.
|
||||
// SetHidden hides/unhides a post from timelines + public collections.
|
||||
// Owner-only — the repo scopes the update to the caller.
|
||||
func (s *PostService) SetHidden(ctx context.Context, postID, callerID string, hidden bool) error {
|
||||
return s.posts.SetHidden(ctx, postID, callerID, hidden)
|
||||
}
|
||||
|
||||
// ReplaceImages swaps the image set on a post. Owner-only — every
|
||||
// reshare is its own posts row, owned by the resharer, so this works
|
||||
// uniformly for canonical posts and reshares.
|
||||
func (s *PostService) ReplaceImages(ctx context.Context, postID, callerID string, imageURLs []string) (*model.Post, error) {
|
||||
if err := s.posts.ReplaceImages(ctx, postID, callerID, imageURLs); err != nil {
|
||||
return nil, err
|
||||
@@ -81,19 +190,232 @@ func (s *PostService) ReplaceImages(ctx context.Context, postID, callerID string
|
||||
return s.posts.FindByID(ctx, postID)
|
||||
}
|
||||
|
||||
// ListByHandle is the public feed view.
|
||||
func (s *PostService) ListByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
|
||||
// ListByHandle is the public feed view. `viewerID` is "" for anonymous
|
||||
// callers; when set, each returned post carries the viewer's reshare
|
||||
// state.
|
||||
func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string, limit, offset int) ([]model.Post, int, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return s.posts.ListByHandle(ctx, handle, limit, offset)
|
||||
posts, total, err := s.posts.ListByHandle(ctx, handle, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydrateReshareStatesSlice(ctx, viewerID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydrateLikesSlice(ctx, viewerID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydratePinnedSlice(ctx, viewerID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// ListMine is the owner's view of their own posts (drafts feature isn't a
|
||||
// thing yet, so this is equivalent to ListByHandle minus the handle lookup).
|
||||
// Feed is the global, personalised home feed: posts from across the platform,
|
||||
// prioritising not-yet-liked posts from people the viewer follows. Fully
|
||||
// hydrated (reshare/like/mention/comment state) plus the viewer↔author
|
||||
// relationship so the card can show Follow / Follow Back. Returns at most
|
||||
// `limit` posts; the caller infers "more" from a full page.
|
||||
func (s *PostService) Feed(ctx context.Context, viewerID string, limit, offset int) ([]model.Post, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
// Non-repetition: a per-user Redis "seen" set is the cursor. offset==0 is
|
||||
// the start of a fresh session, so we clear it; every page then excludes
|
||||
// already-served ids. Because the feed's tier ordering depends on viewer
|
||||
// state (a like re-tiers a post), exclusion — not OFFSET — is what keeps
|
||||
// pages from repeating or skipping rows. With Redis enabled we drive
|
||||
// selection by the set alone (offset 0); without it we fall back to plain
|
||||
// offset paging and the client de-dups the occasional drift.
|
||||
if offset == 0 {
|
||||
s.feedSeen.Reset(ctx, viewerID)
|
||||
}
|
||||
exclude := s.feedSeen.Seen(ctx, viewerID)
|
||||
queryOffset := offset
|
||||
if s.feedSeen.Enabled() {
|
||||
queryOffset = 0
|
||||
}
|
||||
posts, err := s.posts.FeedForViewer(ctx, viewerID, limit, queryOffset, exclude)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.hydrateReshareStatesSlice(ctx, viewerID, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.hydrateLikesSlice(ctx, viewerID, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.hydratePinnedSlice(ctx, viewerID, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.posts.HydrateViewerRelationships(ctx, viewerID, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Record what we served so the next page skips it (no-op without Redis).
|
||||
if len(posts) > 0 {
|
||||
ids := make([]string, len(posts))
|
||||
for i := range posts {
|
||||
ids[i] = posts[i].ID
|
||||
}
|
||||
s.feedSeen.MarkSeen(ctx, viewerID, ids)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
// ResetFeed clears the viewer's home-feed seen-set — called on logout and when
|
||||
// the session ends (the page unloads / the realtime socket drops), so a stale
|
||||
// set doesn't linger in Redis past its owner. No-op without Redis.
|
||||
func (s *PostService) ResetFeed(ctx context.Context, viewerID string) {
|
||||
s.feedSeen.Reset(ctx, viewerID)
|
||||
}
|
||||
|
||||
// hydratePinnedSlice fills PinnedComment on a value slice.
|
||||
func (s *PostService) hydratePinnedSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
||||
if len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
ptrs := make([]*model.Post, len(posts))
|
||||
for i := range posts {
|
||||
ptrs[i] = &posts[i]
|
||||
}
|
||||
return s.comments.Hydrate(ctx, viewerID, ptrs)
|
||||
}
|
||||
|
||||
// hydrateLikesSlice fills LikesCount + ViewerHasLiked on a value slice.
|
||||
func (s *PostService) hydrateLikesSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
||||
if len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
ptrs := make([]*model.Post, len(posts))
|
||||
for i := range posts {
|
||||
ptrs[i] = &posts[i]
|
||||
}
|
||||
return s.likes.Hydrate(ctx, viewerID, ptrs)
|
||||
}
|
||||
|
||||
// hydrateMentionsSlice fills MentionsCount on a value slice.
|
||||
func (s *PostService) hydrateMentionsSlice(ctx context.Context, posts []model.Post) error {
|
||||
if len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
ptrs := make([]*model.Post, len(posts))
|
||||
for i := range posts {
|
||||
ptrs[i] = &posts[i]
|
||||
}
|
||||
return s.mentions.HydrateCounts(ctx, ptrs)
|
||||
}
|
||||
|
||||
// SuggestMentions returns prioritised mention suggestions for the composer.
|
||||
func (s *PostService) SuggestMentions(ctx context.Context, viewerID, q string, limit, offset int) ([]model.UserSummary, error) {
|
||||
return s.mentions.Suggest(ctx, viewerID, q, limit, offset)
|
||||
}
|
||||
|
||||
// Mentions lists the users tagged on a post.
|
||||
func (s *PostService) Mentions(ctx context.Context, postID string) ([]model.UserSummary, error) {
|
||||
return s.mentions.ListForPost(ctx, postID)
|
||||
}
|
||||
|
||||
// AddMention / RemoveMention edit a post's tags — author only.
|
||||
func (s *PostService) AddMention(ctx context.Context, viewerID, postID, targetUserID string) error {
|
||||
if err := s.requireAuthor(ctx, viewerID, postID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.mentions.Add(ctx, postID, targetUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.notif != nil {
|
||||
go s.notif.NotifyPostMention(context.Background(), targetUserID, viewerID, postID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// notifyMentions notifies each freshly-tagged user (deduped, never the author).
|
||||
func (s *PostService) notifyMentions(ctx context.Context, postID, authorID string, userIDs []string) {
|
||||
if s.notif == nil {
|
||||
return
|
||||
}
|
||||
seen := make(map[string]bool, len(userIDs))
|
||||
for _, uid := range userIDs {
|
||||
if uid == "" || uid == authorID || seen[uid] {
|
||||
continue
|
||||
}
|
||||
seen[uid] = true
|
||||
s.notif.NotifyPostMention(ctx, uid, authorID, postID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostService) RemoveMention(ctx context.Context, viewerID, postID, targetUserID string) error {
|
||||
if err := s.requireAuthor(ctx, viewerID, postID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mentions.Remove(ctx, postID, targetUserID)
|
||||
}
|
||||
|
||||
func (s *PostService) requireAuthor(ctx context.Context, viewerID, postID string) error {
|
||||
p, err := s.posts.FindByID(ctx, postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.UserID != viewerID {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Like / Unlike toggle the caller's like on a post and return the effective
|
||||
// total (likes_count + pending Redis delta).
|
||||
func (s *PostService) Like(ctx context.Context, userID, postID string) (int, error) {
|
||||
changed, err := s.likes.Like(ctx, postID, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count, err := s.likes.EffectiveCount(ctx, postID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Notify the author on a genuinely new like by someone else. Fire-and-
|
||||
// forget on a detached context so it isn't cancelled when the request
|
||||
// returns; NotifyPostLike re-checks self-like internally too.
|
||||
if changed && s.notif != nil {
|
||||
if authorID, aerr := s.likes.PostAuthor(ctx, postID); aerr == nil && authorID != userID {
|
||||
go s.notif.NotifyPostLike(context.Background(), postID, authorID, userID)
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *PostService) Unlike(ctx context.Context, userID, postID string) (int, error) {
|
||||
if _, err := s.likes.Unlike(ctx, postID, userID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.likes.EffectiveCount(ctx, postID)
|
||||
}
|
||||
|
||||
// Likers returns one page of users who liked the post + the total count.
|
||||
func (s *PostService) Likers(ctx context.Context, postID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||
return s.likes.ListLikers(ctx, postID, q, limit, offset)
|
||||
}
|
||||
|
||||
// ListMine is the owner's view of their own profile feed. Every post
|
||||
// they own — authored or reshared — comes from posts.user_id, so the
|
||||
// viewer is by construction the owner. ViewerHasReshared is true for
|
||||
// reshare rows (the user IS the resharer) and false for canonical
|
||||
// rows (you don't "reshare your own post").
|
||||
func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
@@ -101,7 +423,55 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return s.posts.ListByUser(ctx, userID, limit, offset)
|
||||
posts, total, err := s.posts.ListByUser(ctx, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i := range posts {
|
||||
if posts[i].ResharedFromPostID != nil {
|
||||
posts[i].ViewerHasReshared = true
|
||||
}
|
||||
}
|
||||
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// ListHidden is the owner's "Hidden" tab — their hidden posts only, hydrated
|
||||
// like ListMine so the cards render fully and the menu offers Unhide.
|
||||
func (s *PostService) ListHidden(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
posts, total, err := s.posts.ListHiddenByUser(ctx, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i := range posts {
|
||||
if posts[i].ResharedFromPostID != nil {
|
||||
posts[i].ViewerHasReshared = true
|
||||
}
|
||||
}
|
||||
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// ImagePresign returns a one-shot R2 PUT URL for a post image.
|
||||
@@ -320,6 +690,49 @@ func (s *CollectionService) Get(ctx context.Context, id, viewerID string) (*mode
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Hidden posts never appear in PUBLIC collections. Private collections are
|
||||
// owner-only (gated above) and keep hidden posts so the owner can still
|
||||
// see and manage them there.
|
||||
if c.IsPublic {
|
||||
visible := make([]model.Post, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
if !p.IsHidden {
|
||||
visible = append(visible, p)
|
||||
}
|
||||
}
|
||||
posts = visible
|
||||
}
|
||||
// PostsIn returns posts without images (single-table boundary). Hydrate
|
||||
// them in one batched query so callers — and the post cards rendered
|
||||
// inside collection carousels — can show the post's first image as
|
||||
// background instead of falling back to the empty quote panel.
|
||||
if len(posts) > 0 {
|
||||
ids := make([]string, len(posts))
|
||||
for i := range posts {
|
||||
ids[i] = posts[i].ID
|
||||
}
|
||||
imgs, err := s.posts.ImagesFor(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for i := range posts {
|
||||
posts[i].Images = imgs[posts[i].ID]
|
||||
}
|
||||
// Same batched approach for reshare states so the action footer
|
||||
// on each card swaps Reshare↔Send correctly. Anonymous viewers
|
||||
// (viewerID == "") get all-false at zero cost.
|
||||
if viewerID != "" {
|
||||
states, err := s.posts.ReshareStatesFor(ctx, viewerID, ids)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for i := range posts {
|
||||
if states[posts[i].ID] {
|
||||
posts[i].ViewerHasReshared = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return c, posts, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
// Legacy IANA timezone aliases → canonical names.
|
||||
//
|
||||
// Browsers report Intl.DateTimeFormat().resolvedOptions().timeZone, and the
|
||||
// ICU/CLDR data they ship still uses several LEGACY ids as its canonical
|
||||
// form — Safari and Chrome genuinely say "Asia/Calcutta", not "Asia/Kolkata".
|
||||
// Go's time.LoadLocation resolves these aliases, so they pass validation and
|
||||
// get stored; but a Postgres built against tzdata without the "backward"
|
||||
// links (Debian/Alpine images with the tzdata-legacy split) rejects them in
|
||||
// AT TIME ZONE with SQLSTATE 22023. Everything we store or hand to SQL goes
|
||||
// through canonicalTZ first.
|
||||
var legacyTZ = map[string]string{
|
||||
// Ids ICU/CLDR still reports as canonical (the ones browsers send).
|
||||
"Asia/Calcutta": "Asia/Kolkata",
|
||||
"Asia/Katmandu": "Asia/Kathmandu",
|
||||
"Asia/Rangoon": "Asia/Yangon",
|
||||
"Asia/Saigon": "Asia/Ho_Chi_Minh",
|
||||
"America/Buenos_Aires": "America/Argentina/Buenos_Aires",
|
||||
"America/Catamarca": "America/Argentina/Catamarca",
|
||||
"America/Cordoba": "America/Argentina/Cordoba",
|
||||
"America/Jujuy": "America/Argentina/Jujuy",
|
||||
"America/Mendoza": "America/Argentina/Mendoza",
|
||||
"America/Indianapolis": "America/Indiana/Indianapolis",
|
||||
"America/Louisville": "America/Kentucky/Louisville",
|
||||
"America/Godthab": "America/Nuuk",
|
||||
"Atlantic/Faeroe": "Atlantic/Faroe",
|
||||
"Pacific/Ponape": "Pacific/Pohnpei",
|
||||
"Pacific/Truk": "Pacific/Chuuk",
|
||||
"Europe/Kiev": "Europe/Kyiv",
|
||||
|
||||
// Other common backward links, in case a zone arrives from an older
|
||||
// client or was set by hand.
|
||||
"Asia/Ashkhabad": "Asia/Ashgabat",
|
||||
"Asia/Chongqing": "Asia/Shanghai",
|
||||
"Asia/Chungking": "Asia/Shanghai",
|
||||
"Asia/Dacca": "Asia/Dhaka",
|
||||
"Asia/Harbin": "Asia/Shanghai",
|
||||
"Asia/Istanbul": "Europe/Istanbul",
|
||||
"Asia/Kashgar": "Asia/Urumqi",
|
||||
"Asia/Macao": "Asia/Macau",
|
||||
"Asia/Tel_Aviv": "Asia/Jerusalem",
|
||||
"Asia/Thimbu": "Asia/Thimphu",
|
||||
"Asia/Ujung_Pandang": "Asia/Makassar",
|
||||
"Asia/Ulan_Bator": "Asia/Ulaanbaatar",
|
||||
"Africa/Asmera": "Africa/Asmara",
|
||||
"America/Coral_Harbour": "America/Atikokan",
|
||||
"Australia/ACT": "Australia/Sydney",
|
||||
"Australia/Canberra": "Australia/Sydney",
|
||||
"Australia/NSW": "Australia/Sydney",
|
||||
"Australia/North": "Australia/Darwin",
|
||||
"Australia/Queensland": "Australia/Brisbane",
|
||||
"Australia/South": "Australia/Adelaide",
|
||||
"Australia/Tasmania": "Australia/Hobart",
|
||||
"Australia/Victoria": "Australia/Melbourne",
|
||||
"Australia/West": "Australia/Perth",
|
||||
"US/Alaska": "America/Anchorage",
|
||||
"US/Arizona": "America/Phoenix",
|
||||
"US/Central": "America/Chicago",
|
||||
"US/Eastern": "America/New_York",
|
||||
"US/Hawaii": "Pacific/Honolulu",
|
||||
"US/Mountain": "America/Denver",
|
||||
"US/Pacific": "America/Los_Angeles",
|
||||
"GB": "Europe/London",
|
||||
"GB-Eire": "Europe/London",
|
||||
"Eire": "Europe/Dublin",
|
||||
"Egypt": "Africa/Cairo",
|
||||
"Hongkong": "Asia/Hong_Kong",
|
||||
"Iceland": "Atlantic/Reykjavik",
|
||||
"Israel": "Asia/Jerusalem",
|
||||
"Japan": "Asia/Tokyo",
|
||||
"NZ": "Pacific/Auckland",
|
||||
"Poland": "Europe/Warsaw",
|
||||
"Portugal": "Europe/Lisbon",
|
||||
"PRC": "Asia/Shanghai",
|
||||
"ROC": "Asia/Taipei",
|
||||
"ROK": "Asia/Seoul",
|
||||
"Singapore": "Asia/Singapore",
|
||||
"Turkey": "Europe/Istanbul",
|
||||
}
|
||||
|
||||
// canonicalTZ returns the canonical IANA name for tz, resolving legacy
|
||||
// aliases; unknown values pass through unchanged.
|
||||
func canonicalTZ(tz string) string {
|
||||
if c, ok := legacyTZ[tz]; ok {
|
||||
return c
|
||||
}
|
||||
return tz
|
||||
}
|
||||
@@ -35,28 +35,148 @@ func NewUserService(users *repository.UserRepo, r2 *repository.R2) *UserService
|
||||
// `handle` is normalized to the project's slug format before being passed
|
||||
// to the DB so users can't sneak in spaces or punctuation. Empty after
|
||||
// normalization → 400.
|
||||
func (s *UserService) UpdateProfile(ctx context.Context, id string, name, avatarURL, handle *string) (*model.User, error) {
|
||||
if name != nil {
|
||||
trimmed := strings.TrimSpace(*name)
|
||||
// UpdateProfileInput is the partial profile patch a handler hands the
|
||||
// service. Every field is a pointer so nil means "leave unchanged". The
|
||||
// service validates/normalizes each present field, then maps to a
|
||||
// repository.ProfileUpdate.
|
||||
type UpdateProfileInput struct {
|
||||
Name *string
|
||||
AvatarURL *string
|
||||
Handle *string
|
||||
Country *string
|
||||
Sampradaya *string
|
||||
Gender *string
|
||||
Initiated *bool
|
||||
InitiatedAt *string
|
||||
DailyChantingRounds *int
|
||||
PreferredChantingTime *string
|
||||
PeakActiveTime *string
|
||||
}
|
||||
|
||||
// validGenders is the accepted set; "" clears the field.
|
||||
var validGenders = map[string]bool{"male": true, "female": true, "other": true}
|
||||
|
||||
// validSampradayas is the accepted lineage set. Currently just Gaudiya
|
||||
// (the default); add codes here as more lineages are supported.
|
||||
var validSampradayas = map[string]bool{"gaudiya": true}
|
||||
|
||||
func (s *UserService) UpdateProfile(ctx context.Context, id string, in UpdateProfileInput) (*model.User, error) {
|
||||
p := repository.ProfileUpdate{
|
||||
AvatarURL: in.AvatarURL, // passthrough: nil=leave, ""=clear, x=set
|
||||
Initiated: in.Initiated,
|
||||
DailyChantingRounds: in.DailyChantingRounds,
|
||||
}
|
||||
|
||||
if in.Name != nil {
|
||||
trimmed := strings.TrimSpace(*in.Name)
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("name cannot be empty")
|
||||
}
|
||||
if len(trimmed) > 120 {
|
||||
return nil, errors.New("name too long (max 120)")
|
||||
}
|
||||
name = &trimmed
|
||||
p.Name = &trimmed
|
||||
}
|
||||
if handle != nil {
|
||||
clean := normalizeHandle(*handle)
|
||||
if in.Handle != nil {
|
||||
clean := normalizeHandle(*in.Handle)
|
||||
if len(clean) < 3 {
|
||||
return nil, errors.New("handle must be at least 3 characters (letters, digits, dashes)")
|
||||
}
|
||||
if len(clean) > 32 {
|
||||
return nil, errors.New("handle too long (max 32)")
|
||||
}
|
||||
handle = &clean
|
||||
p.Handle = &clean
|
||||
}
|
||||
return s.users.UpdateProfile(ctx, id, name, avatarURL, handle)
|
||||
if in.Country != nil {
|
||||
// "" clears; otherwise a 2-letter ISO-3166-1 alpha-2 code (the
|
||||
// frontend dropdown only ever sends valid codes).
|
||||
cc := strings.ToUpper(strings.TrimSpace(*in.Country))
|
||||
if cc != "" && len(cc) != 2 {
|
||||
return nil, errors.New("country must be a 2-letter code")
|
||||
}
|
||||
p.Country = &cc
|
||||
}
|
||||
if in.Sampradaya != nil {
|
||||
// "" clears (optional field); otherwise must be a known lineage.
|
||||
s := strings.ToLower(strings.TrimSpace(*in.Sampradaya))
|
||||
if s != "" && !validSampradayas[s] {
|
||||
return nil, errors.New("unknown sampradaya")
|
||||
}
|
||||
p.Sampradaya = &s
|
||||
}
|
||||
if in.Gender != nil {
|
||||
g := strings.ToLower(strings.TrimSpace(*in.Gender))
|
||||
if g != "" && !validGenders[g] {
|
||||
return nil, errors.New("gender must be male, female, or other")
|
||||
}
|
||||
p.Gender = &g
|
||||
}
|
||||
if in.InitiatedAt != nil {
|
||||
d := strings.TrimSpace(*in.InitiatedAt)
|
||||
if d != "" {
|
||||
if _, err := time.Parse("2006-01-02", d); err != nil {
|
||||
return nil, errors.New("initiated_at must be a date (YYYY-MM-DD)")
|
||||
}
|
||||
}
|
||||
p.InitiatedAt = &d
|
||||
}
|
||||
if in.DailyChantingRounds != nil {
|
||||
// 0 is the clear sentinel; real values are 1–1008 (DB enforces too).
|
||||
n := *in.DailyChantingRounds
|
||||
if n != 0 && (n < 1 || n > 1008) {
|
||||
return nil, errors.New("daily_chanting_rounds must be between 1 and 1008")
|
||||
}
|
||||
}
|
||||
if in.PreferredChantingTime != nil {
|
||||
t, err := normalizeClock(*in.PreferredChantingTime)
|
||||
if err != nil {
|
||||
return nil, errors.New("preferred_chanting_time must be HH:MM")
|
||||
}
|
||||
p.PreferredChantingTime = &t
|
||||
}
|
||||
if in.PeakActiveTime != nil {
|
||||
t, err := normalizeClock(*in.PeakActiveTime)
|
||||
if err != nil {
|
||||
return nil, errors.New("peak_active_time must be HH:MM")
|
||||
}
|
||||
p.PeakActiveTime = &t
|
||||
}
|
||||
|
||||
return s.users.UpdateProfile(ctx, id, p)
|
||||
}
|
||||
|
||||
// normalizeClock validates an "HH:MM" 24-hour clock time, returning "" for
|
||||
// a blank input (clear). The system-managed notification fields use this.
|
||||
func normalizeClock(raw string) (string, error) {
|
||||
t := strings.TrimSpace(raw)
|
||||
if t == "" {
|
||||
return "", nil
|
||||
}
|
||||
if _, err := time.Parse("15:04", t); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Search returns the `offset`/`limit` page of users matching the query
|
||||
// (handle or name substring) plus the total match count for pagination.
|
||||
// Used by the public user-search endpoint on the /search page.
|
||||
func (s *UserService) Search(ctx context.Context, q string, limit, offset int) ([]model.User, int, error) {
|
||||
return s.users.Search(ctx, q, limit, offset)
|
||||
}
|
||||
|
||||
// GetReadingPrefs returns the user's reading prefs blob (opaque JSON).
|
||||
// Used by the frontend to hydrate ReadingSettings on sign-in so the
|
||||
// theme/palette/font choices roam across devices.
|
||||
func (s *UserService) GetReadingPrefs(ctx context.Context, id string) ([]byte, error) {
|
||||
return s.users.GetReadingPrefs(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateReadingPrefs overwrites the user's reading prefs blob. The
|
||||
// handler re-marshals the decoded request before calling in here so we
|
||||
// know `prefsJSON` is well-formed by the time it hits the DB.
|
||||
func (s *UserService) UpdateReadingPrefs(ctx context.Context, id string, prefsJSON []byte) error {
|
||||
return s.users.UpdateReadingPrefs(ctx, id, prefsJSON)
|
||||
}
|
||||
|
||||
// normalizeHandle lowercases and strips anything that isn't [a-z0-9-]. Same
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// LikesWorker periodically folds the pending Redis like-deltas into
|
||||
// posts.likes_count, so the hot counter is written in batches rather than on
|
||||
// every like. It also does a best-effort final flush on shutdown so deltas
|
||||
// aren't lost.
|
||||
type LikesWorker struct {
|
||||
repo *repository.LikeRepo
|
||||
interval time.Duration
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewLikesWorker(repo *repository.LikeRepo, logger *zap.Logger) *LikesWorker {
|
||||
return &LikesWorker{repo: repo, interval: 15 * time.Minute, logger: logger}
|
||||
}
|
||||
|
||||
// Run starts the flush loop in its own goroutine; it exits when ctx is done.
|
||||
func (w *LikesWorker) Run(ctx context.Context) {
|
||||
go func() {
|
||||
w.logger.Info("likes worker started", zap.Duration("interval", w.interval))
|
||||
defer w.logger.Info("likes worker stopped")
|
||||
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Final flush so a graceful shutdown doesn't drop pending
|
||||
// deltas. Fresh, short-lived context since ctx is cancelled.
|
||||
flushCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if _, err := w.repo.FlushDeltas(flushCtx); err != nil {
|
||||
w.logger.Warn("likes worker: final flush failed", zap.Error(err))
|
||||
}
|
||||
cancel()
|
||||
return
|
||||
case <-ticker.C:
|
||||
n, err := w.repo.FlushDeltas(ctx)
|
||||
if err != nil {
|
||||
w.logger.Warn("likes worker: flush failed", zap.Error(err))
|
||||
} else if n > 0 {
|
||||
w.logger.Info("likes worker: flushed", zap.Int("posts", n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ReminderWorker ticks every reminderInterval and asks the ReminderService to
|
||||
// fire any sādhana reminders due at that moment (in each user's local time).
|
||||
// The window inside the service is wider than this interval, so no target
|
||||
// time slips between ticks.
|
||||
type ReminderWorker struct {
|
||||
svc *service.ReminderService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
const reminderInterval = 15 * time.Minute
|
||||
|
||||
func NewReminderWorker(svc *service.ReminderService, logger *zap.Logger) *ReminderWorker {
|
||||
return &ReminderWorker{svc: svc, logger: logger}
|
||||
}
|
||||
|
||||
// Run starts the ticker loop in a goroutine; it exits when ctx is cancelled.
|
||||
func (w *ReminderWorker) Run(ctx context.Context) {
|
||||
go func() {
|
||||
w.logger.Info("sadhna reminder worker started", zap.Duration("interval", reminderInterval))
|
||||
t := time.NewTicker(reminderInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
w.svc.RunOnce(ctx, time.Now())
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS reading_prefs;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Adds per-user reading preferences (theme palette, font family, font
|
||||
-- size, justification toggle) so the prefs roam across devices once the
|
||||
-- user signs in.
|
||||
--
|
||||
-- Stored as JSONB rather than a column-per-field — the shape is owned
|
||||
-- by the frontend (components/reading-settings.tsx), changes
|
||||
-- occasionally (new palettes added every now and then), and we never
|
||||
-- query individual keys server-side. JSONB keeps the schema stable as
|
||||
-- the shape evolves.
|
||||
--
|
||||
-- Default `'{}'::jsonb` so existing rows are not nulled and the
|
||||
-- frontend can treat the server response as "merge into local defaults"
|
||||
-- without an additional null check.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS reading_prefs JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_post_reshares_user_created;
|
||||
DROP TABLE IF EXISTS post_reshares;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Tracks which posts a user has reshared. Pure join table — no
|
||||
-- denormalised counters, no duplicate post rows. The act of resharing
|
||||
-- is a relationship between (post, user); the post itself stays in
|
||||
-- one canonical row owned by its original author.
|
||||
--
|
||||
-- This powers the post-card swap: posts a user has authored OR
|
||||
-- reshared show the "Send" (external share) button instead of
|
||||
-- "Reshare", because there's nothing left to bring onto the account.
|
||||
--
|
||||
-- Composite PK on (post_id, user_id) makes the relationship unique
|
||||
-- and gives us a covered lookup for the "did this viewer reshare this
|
||||
-- post?" check used in batched feed hydration.
|
||||
--
|
||||
-- Index on (user_id, created_at DESC) keeps "what has this user
|
||||
-- reshared, newest first?" cheap for the day we surface that on the
|
||||
-- profile feed.
|
||||
CREATE TABLE IF NOT EXISTS post_reshares (
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (post_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_post_reshares_user_created
|
||||
ON post_reshares (user_id, created_at DESC);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_feedback_created;
|
||||
DROP TABLE IF EXISTS feedback;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Feedback collects user-submitted bug reports, feature requests, and
|
||||
-- general comments captured by the frontend's /feedback page. Two
|
||||
-- consumers:
|
||||
-- 1. The team — every insert fans out an HTML notification to every
|
||||
-- address in FEEDBACK_ADMIN_EMAILS via the existing mailer.
|
||||
-- 2. The /stats ops page — aggregate counts + the most-recent N rows
|
||||
-- render together so we can scan recent feedback without having to
|
||||
-- open the DB.
|
||||
--
|
||||
-- user_id is nullable (and ON DELETE SET NULL on cascade) because:
|
||||
-- • Anonymous visitors submit feedback too — they fill in the optional
|
||||
-- contact_email field instead of leaving an account behind.
|
||||
-- • When a user later deletes their account we keep the row but
|
||||
-- forget who sent it. The content stays useful as product signal
|
||||
-- even after the author is gone.
|
||||
--
|
||||
-- contact_email is the soft reply-to surfaced only for the anonymous
|
||||
-- case; the logged-in case derives reply-to from the joined user row's
|
||||
-- email at admin-notification time.
|
||||
--
|
||||
-- Index on created_at DESC keeps the /stats "recent feedback" list
|
||||
-- cheap as the table grows.
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
category TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
contact_email TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_created
|
||||
ON feedback (created_at DESC);
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS oauth_accounts;
|
||||
-- Note: re-imposing NOT NULL on users.password_hash would require backfilling
|
||||
-- any OAuth-only users with a dummy hash; left as a manual step.
|
||||
@@ -0,0 +1,35 @@
|
||||
-- OAuth account linkage.
|
||||
--
|
||||
-- One row per (provider, provider_user_id). A single user can have several
|
||||
-- providers linked (e.g. signed up with email + later linked Google). The
|
||||
-- provider's `sub` (Google) / `id` (Facebook) is the stable identity — email
|
||||
-- is captured for display but is NOT the join key, since providers can let
|
||||
-- users change the email on the account.
|
||||
--
|
||||
-- When a callback fires:
|
||||
-- 1. Look up (provider, provider_user_id) — found → log that user in.
|
||||
-- 2. Otherwise look up users.email — found → link new oauth_accounts row
|
||||
-- to the existing user and log them in.
|
||||
-- 3. Otherwise create a new user (password_hash NULL) + an oauth_accounts
|
||||
-- row, log them in.
|
||||
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider VARCHAR(32) NOT NULL, -- "google" | "facebook"
|
||||
provider_user_id TEXT NOT NULL, -- sub (Google) / id (Facebook)
|
||||
email CITEXT,
|
||||
name TEXT,
|
||||
avatar_url TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (provider, provider_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS oauth_accounts_user_id_idx ON oauth_accounts (user_id);
|
||||
CREATE INDEX IF NOT EXISTS oauth_accounts_provider_idx ON oauth_accounts (provider);
|
||||
|
||||
-- Password hash must be optional now: OAuth-only users have no password.
|
||||
-- Existing email/password users keep their hash; new OAuth users get NULL
|
||||
-- and can set a password later via the forgot-password flow if they want
|
||||
-- a fallback login method.
|
||||
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Reverting this requires backfilling NULL emails with synthetic values
|
||||
-- first; left as a manual step (see operational runbook).
|
||||
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Some auth providers (Facebook in particular) let users sign up without
|
||||
-- linking a verified email — phone-only accounts and accounts whose owner
|
||||
-- declined the `email` permission both fall under this. To accept those
|
||||
-- users we need to allow NULL on users.email.
|
||||
--
|
||||
-- The UNIQUE constraint stays put: Postgres treats NULLs as distinct under
|
||||
-- a regular UNIQUE index (multiple rows with NULL email are allowed) so
|
||||
-- nothing breaks for OAuth-only users with no address on file. Real email
|
||||
-- addresses are still de-duplicated, exactly as before.
|
||||
ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS post_reshare_images;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Per-reshare background image overrides.
|
||||
--
|
||||
-- Previously every viewer of a reshare saw the canonical post_images row —
|
||||
-- so when either the original author or any resharer "Updated background"
|
||||
-- the change leaked across all timelines that referenced the post. This
|
||||
-- table separates "the post's background" from "this user's reshare of
|
||||
-- the post's background": authoring stays in post_images, resharing
|
||||
-- stores its own snapshot here.
|
||||
--
|
||||
-- At reshare time we copy the post's current post_images into this table
|
||||
-- so the resharer's feed renders identically to the author's. After that
|
||||
-- moment the two are independent — the resharer's background lives only
|
||||
-- here, the original author's lives only in post_images, and changes to
|
||||
-- one never touch the other.
|
||||
--
|
||||
-- Cascade: the FK targets the (post_id, user_id) primary key on
|
||||
-- post_reshares. Every path that removes a reshare relationship (the
|
||||
-- author deletes the post, the resharer un-reshares, either user is
|
||||
-- deleted) drops the post_reshares row, which cascades the overrides
|
||||
-- away. The user's stated rule — "should not be in sync except deletion"
|
||||
-- — is preserved end-to-end without any extra application code.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_reshare_images (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (post_id, user_id)
|
||||
REFERENCES post_reshares (post_id, user_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_post_reshare_images_lookup
|
||||
ON post_reshare_images (post_id, user_id, position);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Reverting this requires materialising the new reshared posts back into
|
||||
-- the post_reshares + post_reshare_images join model and then dropping
|
||||
-- the column. Documented but not automated — run manually if a rollback
|
||||
-- is ever actually needed.
|
||||
--
|
||||
-- Step 1: recreate the old tables.
|
||||
-- Step 2: insert one row per reshare back into post_reshares.
|
||||
-- Step 3: copy each reshare's post_images back into post_reshare_images.
|
||||
-- Step 4: delete the materialised reshare rows from `posts`.
|
||||
-- Step 5: drop the column.
|
||||
|
||||
ALTER TABLE posts DROP COLUMN IF EXISTS reshared_from_post_id;
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Reshares become real `posts` rows.
|
||||
--
|
||||
-- Previously a reshare was a join-table relationship between (post, user)
|
||||
-- that pointed at the original author's canonical row. Likes, comments,
|
||||
-- and (most recently) background overrides all had to be plumbed through
|
||||
-- a parallel "per-resharer" path to avoid leaking across timelines. The
|
||||
-- next set of social features (likes, comments) would multiply that
|
||||
-- complexity again.
|
||||
--
|
||||
-- This migration collapses that model: every reshare is its own row in
|
||||
-- `posts`, owned by the resharer, with a `reshared_from_post_id` pointer
|
||||
-- back to the canonical original. Likes and comments will naturally hang
|
||||
-- off post.id and diverge for free; backgrounds are already per-row in
|
||||
-- post_images so the per-reshare override table goes away.
|
||||
--
|
||||
-- Deletion still cascades: the FK uses ON DELETE CASCADE on
|
||||
-- reshared_from_post_id, so an author deleting their post nukes every
|
||||
-- reshare of it (including their post_images via the existing FK).
|
||||
|
||||
-- 1. New self-referential column. Nullable: NULL on canonical posts,
|
||||
-- set on reshares.
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN IF NOT EXISTS reshared_from_post_id UUID
|
||||
REFERENCES posts(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_reshared_from
|
||||
ON posts (reshared_from_post_id)
|
||||
WHERE reshared_from_post_id IS NOT NULL;
|
||||
|
||||
-- Composite index used by ReshareStatesFor — given a viewer + a batch of
|
||||
-- canonical post ids, "which of these has the viewer reshared?".
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_user_reshared_from
|
||||
ON posts (user_id, reshared_from_post_id)
|
||||
WHERE reshared_from_post_id IS NOT NULL;
|
||||
|
||||
-- 2. Backfill: each row in post_reshares becomes a real `posts` row,
|
||||
-- copying the canonical post's textual fields and the timestamp the
|
||||
-- reshare originally happened. The new row is owned by the resharer.
|
||||
--
|
||||
-- Skipped if post_reshares doesn't exist (clean install) — the
|
||||
-- DO block uses to_regclass so a fresh database doesn't error.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.post_reshares') IS NOT NULL THEN
|
||||
INSERT INTO posts (
|
||||
id, user_id, caption, source_url, source_title,
|
||||
created_at, updated_at, reshared_from_post_id
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
r.user_id,
|
||||
p.caption,
|
||||
p.source_url,
|
||||
p.source_title,
|
||||
r.created_at,
|
||||
r.created_at,
|
||||
p.id
|
||||
FROM post_reshares r
|
||||
JOIN posts p ON p.id = r.post_id;
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
-- 3. Carry the per-resharer image overrides over. Each override row maps
|
||||
-- to the newly-inserted post via (reshared_from_post_id, user_id).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.post_reshare_images') IS NOT NULL THEN
|
||||
INSERT INTO post_images (post_id, url, position)
|
||||
SELECT np.id, ri.url, ri.position
|
||||
FROM post_reshare_images ri
|
||||
JOIN posts np
|
||||
ON np.reshared_from_post_id = ri.post_id
|
||||
AND np.user_id = ri.user_id;
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
-- 4. Old tables are no longer referenced anywhere — drop them so the
|
||||
-- schema mirrors the new model.
|
||||
DROP TABLE IF EXISTS post_reshare_images;
|
||||
DROP TABLE IF EXISTS post_reshares;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- This is a data-only backfill — there's no clean rollback because we
|
||||
-- can't distinguish post_images rows added by this migration from rows
|
||||
-- the resharer added afterwards. Intentional no-op; documented for the
|
||||
-- runbook only.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Backfill the "reshares inherit the canonical's images" rule for any
|
||||
-- reshare row that landed in `posts` without an image set.
|
||||
--
|
||||
-- Two paths created such rows:
|
||||
--
|
||||
-- 1. Reshares made BEFORE migration 0014 had no per-resharer image
|
||||
-- table to snapshot into, so when 0015 collapsed the old
|
||||
-- post_reshares model into real posts rows there was nothing for
|
||||
-- it to copy — those reshares now show with a blank background
|
||||
-- where the original author has a cover image.
|
||||
--
|
||||
-- 2. Any future code path that creates a reshare row but forgets to
|
||||
-- copy the images. Defensive — current `Reshare` in
|
||||
-- internal/repository/post_repo.go does copy, but this migration
|
||||
-- makes the database self-healing if we ever regress.
|
||||
--
|
||||
-- The copy is scoped to reshares with ZERO images of their own so we
|
||||
-- never overwrite a resharer's explicit customization (including an
|
||||
-- explicit "no background" — though that path didn't exist at the time
|
||||
-- the affected rows were created, the guard keeps us safe for any
|
||||
-- future re-runs of equivalent backfills).
|
||||
INSERT INTO post_images (post_id, url, position)
|
||||
SELECT r.id, i.url, i.position
|
||||
FROM posts r
|
||||
JOIN post_images i ON i.post_id = r.reshared_from_post_id
|
||||
WHERE r.reshared_from_post_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM post_images WHERE post_id = r.id
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_feedback_unresolved;
|
||||
ALTER TABLE feedback DROP COLUMN IF EXISTS resolved_at;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Track when a feedback row was resolved.
|
||||
--
|
||||
-- NULL = still open. Non-NULL = closed at that moment. The action is
|
||||
-- one-way: the resolve endpoint enforces `WHERE resolved_at IS NULL` so
|
||||
-- a row already marked stays marked, and a subsequent re-resolve is a
|
||||
-- no-op (no new email goes out).
|
||||
--
|
||||
-- We don't store "who" resolved it because the /stats page surfaces
|
||||
-- this to the team and any audit-quality "who closed what" trail would
|
||||
-- want its own table — out of scope for now.
|
||||
ALTER TABLE feedback
|
||||
ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMPTZ;
|
||||
|
||||
-- Partial index makes "give me the still-open feedback" cheap as the
|
||||
-- table grows. The /stats page lists newest-first regardless of state,
|
||||
-- so the existing created_at index still serves its read path.
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_unresolved
|
||||
ON feedback (created_at DESC)
|
||||
WHERE resolved_at IS NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE feedback DROP COLUMN IF EXISTS acknowledged_at;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Track when a feedback row was acknowledged.
|
||||
--
|
||||
-- Acknowledgement is the intermediate state between "open" and
|
||||
-- "resolved": the team has seen the feedback and (if it needs work) the
|
||||
-- devs have started on it. The author gets an email saying so, with a
|
||||
-- promise of a second email once it's actually fixed/implemented.
|
||||
--
|
||||
-- NULL = not yet acknowledged. Non-NULL = acknowledged at that moment.
|
||||
-- Like resolved_at, the action is one-way and idempotent: the endpoint
|
||||
-- enforces `WHERE acknowledged_at IS NULL` so the acknowledgement email
|
||||
-- fires at most once.
|
||||
--
|
||||
-- The two columns are independent stamps. An admin may acknowledge then
|
||||
-- later resolve (two emails), or resolve directly without acknowledging
|
||||
-- (one email). Resolving does not require a prior acknowledgement.
|
||||
ALTER TABLE feedback
|
||||
ADD COLUMN IF NOT EXISTS acknowledged_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS notifications;
|
||||
DROP TABLE IF EXISTS user_reports;
|
||||
DROP TABLE IF EXISTS user_blocks;
|
||||
DROP TABLE IF EXISTS follows;
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Social graph: follows, blocks, reports, plus a generic notifications
|
||||
-- table that backs the "notification plugin" (email today; websocket +
|
||||
-- push will read the same rows later).
|
||||
--
|
||||
-- Follows are REQUEST-BASED. Clicking "Follow" inserts a row with
|
||||
-- status='pending'; the target later accepts (status→'accepted',
|
||||
-- responded_at stamped) or rejects (row deleted). A user is "following"
|
||||
-- another only once the edge is 'accepted'. The UNIQUE(follower,
|
||||
-- following) pair makes a repeated request idempotent, and the CHECK
|
||||
-- stops anyone following themselves.
|
||||
CREATE TABLE IF NOT EXISTS follows (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending | accepted
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
responded_at TIMESTAMPTZ, -- set when accepted
|
||||
UNIQUE (follower_id, following_id),
|
||||
CHECK (follower_id <> following_id)
|
||||
);
|
||||
|
||||
-- "Who follows me / who has requested me" — the incoming side. Filtered
|
||||
-- by status so accepted-follower counts and pending-request lists are
|
||||
-- both index-served.
|
||||
CREATE INDEX IF NOT EXISTS follows_following_status_idx
|
||||
ON follows (following_id, status);
|
||||
|
||||
-- "Who I follow / who I've requested" — the outgoing side.
|
||||
CREATE INDEX IF NOT EXISTS follows_follower_status_idx
|
||||
ON follows (follower_id, status);
|
||||
|
||||
-- Blocks. blocker_id has blocked blocked_id. One row per direction; the
|
||||
-- service treats a block in EITHER direction as "these two can't follow
|
||||
-- each other" and tears down any existing follow edges both ways.
|
||||
CREATE TABLE IF NOT EXISTS user_blocks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (blocker_id, blocked_id),
|
||||
CHECK (blocker_id <> blocked_id)
|
||||
);
|
||||
|
||||
-- Reverse lookup: "has anyone blocked me?" — used to compute blocked_by
|
||||
-- when rendering a profile.
|
||||
CREATE INDEX IF NOT EXISTS user_blocks_blocked_idx
|
||||
ON user_blocks (blocked_id);
|
||||
|
||||
-- Reports. A reporter flags another user with a category + free-text
|
||||
-- reason. resolved_at is one-way (admins clear it later); the partial
|
||||
-- index keeps the open-reports queue cheap to list.
|
||||
CREATE TABLE IF NOT EXISTS user_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
reported_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
category VARCHAR(32) NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
CHECK (reporter_id <> reported_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_reports_reported_idx
|
||||
ON user_reports (reported_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_reports_unresolved_idx
|
||||
ON user_reports (created_at DESC) WHERE resolved_at IS NULL;
|
||||
|
||||
-- Notifications back the in-app/email/push fan-out. Today the
|
||||
-- NotificationService persists a row here AND sends an email; websocket
|
||||
-- + push delivery will later read unread rows for a user. actor_id is
|
||||
-- the user who triggered the event (nullable for system notifications);
|
||||
-- kind is a short machine string ('follow_request', 'follow_accepted').
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- recipient
|
||||
actor_id UUID REFERENCES users(id) ON DELETE CASCADE, -- who caused it
|
||||
kind VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
read_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- "My notifications, newest first" + an unread filter for badge counts.
|
||||
CREATE INDEX IF NOT EXISTS notifications_user_created_idx
|
||||
ON notifications (user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS notifications_user_unread_idx
|
||||
ON notifications (user_id) WHERE read_at IS NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS country;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Account country (ISO-3166-1 alpha-2), shown in the profile "About this
|
||||
-- account" panel. Captured best-effort at signup (Cloudflare's
|
||||
-- CF-IPCountry header when present, else a GeoIP lookup on the client IP)
|
||||
-- and editable by the user from the profile edit form. NULL until known.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS country CHAR(2);
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_daily_chanting_rounds_range;
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS gender,
|
||||
DROP COLUMN IF EXISTS initiated,
|
||||
DROP COLUMN IF EXISTS initiated_at,
|
||||
DROP COLUMN IF EXISTS daily_chanting_rounds,
|
||||
DROP COLUMN IF EXISTS preferred_chanting_time,
|
||||
DROP COLUMN IF EXISTS peak_active_time;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Devotional profile details, plus two system-managed fields reserved for
|
||||
-- the (not-yet-built) automated-notification scheduler.
|
||||
--
|
||||
-- gender / initiated / initiated_at / daily_chanting_rounds are
|
||||
-- user-editable from the profile form. preferred_chanting_time and
|
||||
-- peak_active_time are written silently by the backend later (no UI yet) —
|
||||
-- both store a local clock time as "HH:MM" text so the scheduler can pick
|
||||
-- a send window without timezone math at write time.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS gender VARCHAR(16),
|
||||
ADD COLUMN IF NOT EXISTS initiated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS initiated_at DATE,
|
||||
ADD COLUMN IF NOT EXISTS daily_chanting_rounds INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS preferred_chanting_time VARCHAR(5),
|
||||
ADD COLUMN IF NOT EXISTS peak_active_time VARCHAR(5);
|
||||
|
||||
-- Guard the chanting-round count to the meaningful range (1 round up to a
|
||||
-- full lakh of names = 1008). NULL stays allowed (not specified).
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_daily_chanting_rounds_range;
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_daily_chanting_rounds_range
|
||||
CHECK (daily_chanting_rounds IS NULL
|
||||
OR (daily_chanting_rounds BETWEEN 1 AND 1008));
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS sampradaya;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Devotee's sampradāya (spiritual lineage). Every account defaults to the
|
||||
-- Gaudiya sampradāya; the column is NOT NULL so the value is always
|
||||
-- present. Stored as a lowercase code; the frontend maps it to a display
|
||||
-- label. Existing rows are backfilled to 'gaudiya' by the DEFAULT.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS sampradaya VARCHAR(32) NOT NULL DEFAULT 'gaudiya';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Re-imposing NOT NULL requires backfilling any NULLs first.
|
||||
UPDATE users SET sampradaya = 'gaudiya' WHERE sampradaya IS NULL;
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN sampradaya SET NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Sampradāya is optional. Keep the 'gaudiya' DEFAULT so new accounts still
|
||||
-- start in the Gaudiya lineage, but allow NULL so a devotee can clear it.
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN sampradaya DROP NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS post_reports;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Post reports — a viewer flags a specific post (not its author) with a
|
||||
-- category + optional free-text reason. Mirrors user_reports but keyed to
|
||||
-- a post; resolved_at is one-way (admins clear it later). The partial
|
||||
-- index keeps the open-reports queue cheap to list.
|
||||
CREATE TABLE IF NOT EXISTS post_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
category VARCHAR(32) NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_reports_post_idx
|
||||
ON post_reports (post_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_reports_unresolved_idx
|
||||
ON post_reports (created_at DESC) WHERE resolved_at IS NULL;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS sadhna_logs;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Sādhana streak: one row per user per day on which they chanted.
|
||||
--
|
||||
-- A "day complete" (what the streak + the growing-plant animation are
|
||||
-- built on) is the conjunction of two daily disciplines:
|
||||
-- 1. chanting today's target rounds → recorded here (chant_done)
|
||||
-- 2. posting at least one quote today → derived live from the posts
|
||||
-- table (a post whose created_at falls on the same calendar day)
|
||||
-- so this table only needs to capture the chanting half.
|
||||
--
|
||||
-- log_date is a DATE (not a timestamp) so the streak rolls over at UTC
|
||||
-- midnight without any timezone math, matching how the rest of the app
|
||||
-- buckets "today". chant_done is stored explicitly — decided at save time
|
||||
-- as rounds_completed >= the user's target THEN — so later edits to a
|
||||
-- user's daily_chanting_rounds never retroactively rewrite history.
|
||||
CREATE TABLE IF NOT EXISTS sadhna_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
log_date DATE NOT NULL,
|
||||
rounds_completed INTEGER NOT NULL DEFAULT 0,
|
||||
chant_done BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, log_date),
|
||||
CHECK (rounds_completed >= 0 AND rounds_completed <= 1008)
|
||||
);
|
||||
|
||||
-- The streak/growth computation walks a user's recent days newest-first;
|
||||
-- this serves both that scan and the "today" lookup.
|
||||
CREATE INDEX IF NOT EXISTS sadhna_logs_user_date_idx
|
||||
ON sadhna_logs (user_id, log_date DESC);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS sadhna_reward_grants;
|
||||
DROP TABLE IF EXISTS sadhna_rewards;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user