Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b10e726b7 | ||
|
|
623935df22 | ||
|
|
afcdd7ef19 | ||
|
|
4553725d4e | ||
|
|
47b3a6d96e | ||
|
|
3de7e5faec | ||
|
|
fa3d2b932c | ||
|
|
d29eb5d844 | ||
|
|
c26904b61e | ||
|
|
9c9c46caf1 | ||
|
|
44a0016513 | ||
|
|
96ebbfd719 | ||
|
|
eadef33eac | ||
|
|
fe0bee4ce7 | ||
|
|
d52530c52c | ||
|
|
dc204577a8 | ||
|
|
5c0581a1da | ||
|
|
9235f68402 | ||
|
|
be4b1636c0 | ||
|
|
a63ef672bb | ||
|
|
1cde0c0544 | ||
|
|
04ad94ad3c | ||
|
|
4c42d9a7f9 | ||
|
|
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 |
+29
-1
@@ -24,14 +24,24 @@ R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET=
|
||||
# Public base URL where bucket objects are served (custom domain or pub-XXX.r2.dev)
|
||||
R2_PUBLIC_URL=
|
||||
# Separate bucket for private media (DM attachments), read only via short-lived
|
||||
# presigned GETs. It must have NO public access and no custom domain: R2 public
|
||||
# access is per-bucket, so a prefix inside R2_BUCKET would still be world-readable.
|
||||
# Empty disables private-media endpoints (they return 503).
|
||||
R2_PRIVATE_BUCKET=
|
||||
|
||||
# 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 +58,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()
|
||||
|
||||
+65
-17
@@ -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{
|
||||
Action: &action,
|
||||
})
|
||||
// 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
|
||||
@@ -279,11 +307,17 @@ func indexVerses(sugar *zap.SugaredLogger, tsClient *typesense.Client, mongoDB *
|
||||
TokenSeparators: &hyphenSeparators,
|
||||
}
|
||||
|
||||
_, err := tsClient.Collections().Create(context.Background(), schema)
|
||||
if err != nil {
|
||||
sugar.Fatalf("[verses] Failed to create collection: %v", err)
|
||||
// Create the collection; tolerate "already exists" so a --no-drop run can
|
||||
// upsert into the live collection without dropping it (matches indexProse).
|
||||
if _, err := tsClient.Collections().Create(context.Background(), schema); err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
sugar.Warnf("[verses] Create collection: %v (continuing — upserting into existing)", err)
|
||||
} else {
|
||||
sugar.Fatalf("[verses] Failed to create collection: %v", err)
|
||||
}
|
||||
} else {
|
||||
sugar.Info("[verses] Collection created")
|
||||
}
|
||||
sugar.Info("[verses] Collection created")
|
||||
|
||||
// Read from MongoDB
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
@@ -371,9 +405,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")
|
||||
}
|
||||
sugar.Info("[prose] Collection created")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
@@ -406,11 +443,20 @@ 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)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -428,7 +474,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,26 @@ 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.
|
||||
// R2PrivateBucket is a SEPARATE bucket with no public access, used for
|
||||
// objects that must never be fetchable by URL alone (DM attachments).
|
||||
// A prefix inside R2Bucket would not do: R2 public access is per-bucket,
|
||||
// so anything in that bucket is world-readable. Empty disables the
|
||||
// private-media endpoints (they return 503) — the BFF still boots.
|
||||
R2PrivateBucket string
|
||||
|
||||
// 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 +72,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).
|
||||
@@ -88,6 +143,7 @@ func Load() (*Config, error) {
|
||||
R2SecretAccessKey: getEnv("R2_SECRET_ACCESS_KEY", ""),
|
||||
R2Bucket: getEnv("R2_BUCKET", ""),
|
||||
R2PublicURL: getEnv("R2_PUBLIC_URL", ""),
|
||||
R2PrivateBucket: getEnv("R2_PRIVATE_BUCKET", ""),
|
||||
|
||||
SMTPHost: getEnv("SMTP_HOST", ""),
|
||||
SMTPPort: getEnvInt("SMTP_PORT", 587),
|
||||
@@ -95,6 +151,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", ""),
|
||||
@@ -106,7 +164,27 @@ func Load() (*Config, error) {
|
||||
CookieDomain: getEnv("COOKIE_DOMAIN", ""),
|
||||
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
||||
|
||||
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
|
||||
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 +225,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,425 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
// Stats — GET /admin/stats → system snapshot for the dashboard home.
|
||||
func (h *AdminHandler) Stats(w http.ResponseWriter, r *http.Request) {
|
||||
snap, err := h.svc.Stats(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load stats")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, snap)
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
// AcknowledgeFeedback — POST /admin/feedback/{id}/acknowledge
|
||||
func (h *AdminHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
err := h.svc.AcknowledgeFeedback(r.Context(), chi.URLParam(r, "id"))
|
||||
// Already-acknowledged is a no-op success from the client's perspective.
|
||||
if err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) {
|
||||
response.Error(w, http.StatusInternalServerError, "could not acknowledge feedback")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"acknowledged": true})
|
||||
}
|
||||
|
||||
// ResolveFeedback — POST /admin/feedback/{id}/resolve
|
||||
func (h *AdminHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
err := h.svc.ResolveFeedback(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil && !errors.Is(err, service.ErrAlreadyResolved) {
|
||||
response.Error(w, http.StatusInternalServerError, "could not resolve feedback")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"resolved": true})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
var in struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
// Body is optional/best-effort; an empty/missing note resolves with no note.
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
resolved, err := h.svc.ResolveReport(r.Context(), reportType, id, strings.TrimSpace(in.Note))
|
||||
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})
|
||||
}
|
||||
|
||||
// SetUserLimits — POST /admin/users/{id}/limits with body
|
||||
// { "bookmark_limit": 10|null, "public_collection_limit": …, "private_collection_limit": … }.
|
||||
// Each field is optional: a null (or omitted) value resets that limit to the
|
||||
// plan default; a number caps that resource for this user regardless of plan.
|
||||
// ListDisciples — GET /admin/disciples?q=&limit= — options for the
|
||||
// direct-disciple picker. Read-only; an empty q returns the first page
|
||||
// alphabetically so the picker opens with content.
|
||||
func (h *AdminHandler) ListDisciples(w http.ResponseWriter, r *http.Request) {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
items, err := h.svc.SearchDisciples(r.Context(), r.URL.Query().Get("q"), limit)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrDisciplesUnavailable) {
|
||||
response.Error(w, http.StatusServiceUnavailable, "disciple directory unavailable")
|
||||
return
|
||||
}
|
||||
response.Error(w, http.StatusInternalServerError, "could not search disciples")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, items)
|
||||
}
|
||||
|
||||
// LinkDisciple — POST /admin/users/{id}/disciple {"disciple_id":"…"} — marks the
|
||||
// account as one of Śrīla Prabhupāda's direct disciples. Re-posting with a
|
||||
// different id moves the link.
|
||||
func (h *AdminHandler) LinkDisciple(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var in struct {
|
||||
DiscipleID string `json:"disciple_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(in.DiscipleID) == "" {
|
||||
response.Error(w, http.StatusBadRequest, "disciple_id is required")
|
||||
return
|
||||
}
|
||||
switch err := h.svc.LinkDisciple(r.Context(), id, in.DiscipleID); {
|
||||
case err == nil:
|
||||
response.JSON(w, http.StatusOK, map[string]any{"linked": true})
|
||||
case errors.Is(err, service.ErrAdminUserNotFound):
|
||||
response.Error(w, http.StatusNotFound, "user not found")
|
||||
case errors.Is(err, service.ErrDiscipleNotFound):
|
||||
response.Error(w, http.StatusNotFound, "disciple not found")
|
||||
case errors.Is(err, repository.ErrDiscipleClaimed):
|
||||
response.Error(w, http.StatusConflict, "that disciple is already linked to another user")
|
||||
case errors.Is(err, service.ErrDisciplesUnavailable):
|
||||
response.Error(w, http.StatusServiceUnavailable, "disciple directory unavailable")
|
||||
default:
|
||||
response.Error(w, http.StatusInternalServerError, "could not link disciple")
|
||||
}
|
||||
}
|
||||
|
||||
// UnlinkDisciple — DELETE /admin/users/{id}/disciple — removes the link.
|
||||
func (h *AdminHandler) UnlinkDisciple(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
switch err := h.svc.UnlinkDisciple(r.Context(), id); {
|
||||
case err == nil:
|
||||
response.JSON(w, http.StatusOK, map[string]any{"linked": false})
|
||||
case errors.Is(err, service.ErrAdminUserNotFound):
|
||||
response.Error(w, http.StatusNotFound, "user not found")
|
||||
default:
|
||||
response.Error(w, http.StatusInternalServerError, "could not unlink disciple")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) SetUserLimits(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var in struct {
|
||||
BookmarkLimit *int `json:"bookmark_limit"`
|
||||
PublicCollectionLimit *int `json:"public_collection_limit"`
|
||||
PrivateCollectionLimit *int `json:"private_collection_limit"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
const maxLimit = 100000
|
||||
for _, v := range []*int{in.BookmarkLimit, in.PublicCollectionLimit, in.PrivateCollectionLimit} {
|
||||
if v != nil && (*v < 0 || *v > maxLimit) {
|
||||
response.Error(w, http.StatusBadRequest, "each limit must be between 0 and 100000")
|
||||
return
|
||||
}
|
||||
}
|
||||
ok, err := h.svc.SetUserLimits(r.Context(), id, in.BookmarkLimit, in.PublicCollectionLimit, in.PrivateCollectionLimit)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not update limits")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
response.Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, map[string]any{"updated": true})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// ListShortURLs — GET /admin/short-urls?q=&limit=&offset= — paginated list of
|
||||
// shortened URLs, newest first.
|
||||
func (h *AdminHandler) ListShortURLs(w http.ResponseWriter, r *http.Request) {
|
||||
q, limit, offset := pageParams(r)
|
||||
items, total, err := h.svc.ListShortURLs(r.Context(), q, limit, offset)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not list short urls")
|
||||
return
|
||||
}
|
||||
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
|
||||
}
|
||||
|
||||
// ShortURLStats — GET /admin/short-urls/stats?from=YYYY-MM-DD&to=YYYY-MM-DD —
|
||||
// today/week/month/all-time counts, plus a custom range when from+to are given.
|
||||
func (h *AdminHandler) ShortURLStats(w http.ResponseWriter, r *http.Request) {
|
||||
from, to := parseDateRange(r)
|
||||
stats, err := h.svc.ShortURLStats(r.Context(), from, to)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load short url stats")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// parseDateRange reads ?from=YYYY-MM-DD&to=YYYY-MM-DD (UTC). `to` is treated as
|
||||
// inclusive, so the returned upper bound is the exclusive start of the next
|
||||
// day. Returns (nil, nil) unless both dates are present and valid.
|
||||
func parseDateRange(r *http.Request) (*time.Time, *time.Time) {
|
||||
fs := r.URL.Query().Get("from")
|
||||
ts := r.URL.Query().Get("to")
|
||||
if fs == "" || ts == "" {
|
||||
return nil, nil
|
||||
}
|
||||
from, err1 := time.ParseInLocation("2006-01-02", fs, time.UTC)
|
||||
to, err2 := time.ParseInLocation("2006-01-02", ts, time.UTC)
|
||||
if err1 != nil || err2 != nil {
|
||||
return nil, nil
|
||||
}
|
||||
toExclusive := to.AddDate(0, 0, 1)
|
||||
return &from, &toExclusive
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// maxAudioProgressItems bounds a single upsert payload. A normal save
|
||||
// sends one item; the on-login sync sends the user's whole local set,
|
||||
// which is realistically small. The cap just guards against abuse.
|
||||
const maxAudioProgressItems = 2000
|
||||
|
||||
// AudioProgressHandler serves the audio player's per-file resume points.
|
||||
// All routes are auth-required; the user comes from the request context
|
||||
// populated by middleware.RequireAuth.
|
||||
type AudioProgressHandler struct {
|
||||
svc *service.AudioProgressService
|
||||
}
|
||||
|
||||
func NewAudioProgressHandler(svc *service.AudioProgressService) *AudioProgressHandler {
|
||||
return &AudioProgressHandler{svc: svc}
|
||||
}
|
||||
|
||||
type audioProgressItems struct {
|
||||
Items []model.AudioProgress `json:"items"`
|
||||
}
|
||||
|
||||
// Get — GET /users/me/audio-progress. Returns every saved resume point so
|
||||
// the client can hydrate its in-memory map on sign-in.
|
||||
func (h *AudioProgressHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
items, err := h.svc.List(r.Context(), uid)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load audio progress")
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []model.AudioProgress{}
|
||||
}
|
||||
response.JSON(w, http.StatusOK, audioProgressItems{Items: items})
|
||||
}
|
||||
|
||||
// Put — PUT /users/me/audio-progress. Upserts a batch of resume points
|
||||
// (one item for a normal save; N for the on-login sync). Newest-wins is
|
||||
// enforced in SQL, so order/conflicts are safe.
|
||||
func (h *AudioProgressHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
|
||||
var body audioProgressItems
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body (expect { items: [...] })")
|
||||
return
|
||||
}
|
||||
if len(body.Items) > maxAudioProgressItems {
|
||||
response.Error(w, http.StatusBadRequest, "too many items")
|
||||
return
|
||||
}
|
||||
|
||||
clean := make([]model.AudioProgress, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
if it.Key == "" || it.Position < 0 {
|
||||
continue // drop malformed entries rather than fail the batch
|
||||
}
|
||||
if it.UpdatedAt.IsZero() {
|
||||
it.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
clean = append(clean, it)
|
||||
}
|
||||
|
||||
if err := h.svc.Upsert(r.Context(), uid, clean); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not save audio progress")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Delete — DELETE /users/me/audio-progress?key=<url-encoded key>. Removes a
|
||||
// single resume point (used when a track plays to completion). The key is
|
||||
// a URL, so it travels as a query param to avoid path-encoding issues.
|
||||
func (h *AudioProgressHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
key := r.URL.Query().Get("key")
|
||||
if key == "" {
|
||||
response.Error(w, http.StatusBadRequest, "missing key")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(r.Context(), uid, key); err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not delete audio progress")
|
||||
return
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
+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,174 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ChannelRule authorizes one family of channels.
|
||||
//
|
||||
// Suffix is whatever follows Prefix in the channel name (a user ID today, a
|
||||
// conversation ID once chat lands). Authorize reports whether userID — the
|
||||
// authenticated caller — may subscribe. Returning an error means "couldn't
|
||||
// decide" (a lookup failed) and surfaces as a 500; returning (false, nil)
|
||||
// means "decided: no" and surfaces as a 403.
|
||||
type ChannelRule struct {
|
||||
Prefix string
|
||||
Authorize func(ctx context.Context, userID, suffix string) (bool, error)
|
||||
}
|
||||
|
||||
// BroadcastingAuthHandler implements the Pusher channel-authorization
|
||||
// endpoint that pusher-js calls (as `authEndpoint`) before subscribing to a
|
||||
// private- or presence- channel.
|
||||
//
|
||||
// This is the gate that makes private channels private: Soketi will not honour
|
||||
// a subscription without a signature, and the signature is only minted here,
|
||||
// after RequireAuth has established who the caller is and a rule has agreed
|
||||
// they may listen. Unknown channels are denied by default, so adding a channel
|
||||
// family is an explicit act.
|
||||
type BroadcastingAuthHandler struct {
|
||||
rt *realtime.SoketiClient
|
||||
rules []ChannelRule
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewBroadcastingAuthHandler(rt *realtime.SoketiClient, logger *zap.Logger, rules ...ChannelRule) *BroadcastingAuthHandler {
|
||||
return &BroadcastingAuthHandler{rt: rt, rules: rules, logger: logger}
|
||||
}
|
||||
|
||||
// NotificationsRule allows a user to subscribe to their own notification
|
||||
// channel and nobody else's.
|
||||
func NotificationsRule() ChannelRule {
|
||||
return ChannelRule{
|
||||
Prefix: "private-notifications.",
|
||||
Authorize: func(_ context.Context, userID, suffix string) (bool, error) {
|
||||
return suffix != "" && suffix == userID, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type broadcastingAuthResp struct {
|
||||
Auth string `json:"auth"`
|
||||
ChannelData string `json:"channel_data,omitempty"`
|
||||
}
|
||||
|
||||
// writeAuth emits the auth payload BARE, deliberately bypassing the API's
|
||||
// {success, data} envelope.
|
||||
//
|
||||
// This endpoint speaks Pusher's protocol, not our API's: pusher-js reads
|
||||
// `auth` off the top level of the parsed body, so an enveloped response makes
|
||||
// every private subscription fail. Errors still use response.Error — the
|
||||
// client only checks the status code on that path.
|
||||
func writeAuth(w http.ResponseWriter, payload broadcastingAuthResp) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
// Handle — POST /api/v1/broadcasting/auth. Session-authenticated (the cookie
|
||||
// travels because the frontend sets credentials: "include").
|
||||
//
|
||||
// pusher-js posts application/x-www-form-urlencoded by default; JSON is
|
||||
// accepted too so a custom client isn't forced into form encoding.
|
||||
func (h *BroadcastingAuthHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserID(r.Context())
|
||||
if userID == "" {
|
||||
// RequireAuth should have caught this; refuse rather than sign.
|
||||
response.Error(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
socketID, channel := parseBroadcastingAuthReq(r)
|
||||
if socketID == "" || channel == "" {
|
||||
response.Error(w, http.StatusBadRequest, "socket_id and channel_name are required")
|
||||
return
|
||||
}
|
||||
|
||||
rule, suffix, found := h.match(channel)
|
||||
if !found {
|
||||
// Default-deny. An unrecognised channel is a client bug or an
|
||||
// probe — either way there's nothing to sign.
|
||||
h.logger.Warn("broadcasting auth: unknown channel",
|
||||
zap.String("channel", channel), zap.String("user_id", userID))
|
||||
response.Error(w, http.StatusForbidden, "channel not available")
|
||||
return
|
||||
}
|
||||
|
||||
allowed, err := rule.Authorize(r.Context(), userID, suffix)
|
||||
if err != nil {
|
||||
h.logger.Error("broadcasting auth: authorize failed",
|
||||
zap.String("channel", channel), zap.Error(err))
|
||||
response.Error(w, http.StatusInternalServerError, "could not authorize channel")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
h.logger.Warn("broadcasting auth: denied",
|
||||
zap.String("channel", channel), zap.String("user_id", userID))
|
||||
response.Error(w, http.StatusForbidden, "not permitted on this channel")
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(channel, "presence-") {
|
||||
auth, channelData, err := h.rt.AuthorizePresence(socketID, channel, realtime.PresenceData{UserID: userID})
|
||||
if err != nil {
|
||||
h.authError(w, err)
|
||||
return
|
||||
}
|
||||
writeAuth(w, broadcastingAuthResp{Auth: auth, ChannelData: channelData})
|
||||
return
|
||||
}
|
||||
|
||||
auth, err := h.rt.AuthorizeChannel(socketID, channel)
|
||||
if err != nil {
|
||||
h.authError(w, err)
|
||||
return
|
||||
}
|
||||
writeAuth(w, broadcastingAuthResp{Auth: auth})
|
||||
}
|
||||
|
||||
// match finds the rule owning this channel and returns the part of the name
|
||||
// after its prefix.
|
||||
func (h *BroadcastingAuthHandler) match(channel string) (ChannelRule, string, bool) {
|
||||
for _, rule := range h.rules {
|
||||
if strings.HasPrefix(channel, rule.Prefix) {
|
||||
return rule, strings.TrimPrefix(channel, rule.Prefix), true
|
||||
}
|
||||
}
|
||||
return ChannelRule{}, "", false
|
||||
}
|
||||
|
||||
func (h *BroadcastingAuthHandler) authError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, realtime.ErrNotConfigured) {
|
||||
response.Error(w, http.StatusServiceUnavailable, "realtime is not configured")
|
||||
return
|
||||
}
|
||||
h.logger.Error("broadcasting auth: signing failed", zap.Error(err))
|
||||
response.Error(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
// parseBroadcastingAuthReq reads socket_id / channel_name from either a form
|
||||
// body (pusher-js default) or a JSON body.
|
||||
func parseBroadcastingAuthReq(r *http.Request) (socketID, channel string) {
|
||||
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
var body struct {
|
||||
SocketID string `json:"socket_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return strings.TrimSpace(body.SocketID), strings.TrimSpace(body.ChannelName)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return strings.TrimSpace(r.PostFormValue("socket_id")), strings.TrimSpace(r.PostFormValue("channel_name"))
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const testUser = "user-a"
|
||||
|
||||
func testAuthHandler(t *testing.T) *BroadcastingAuthHandler {
|
||||
t.Helper()
|
||||
rt := realtime.NewSoketiClient("app-1", "test-key", "test-secret", "ws.example.com", 443, true, zap.NewNop())
|
||||
if rt == nil {
|
||||
t.Fatal("realtime client is nil")
|
||||
}
|
||||
// A second rule stands in for the chat conversation channels, proving the
|
||||
// rule table is what decides access rather than anything hardcoded.
|
||||
convRule := ChannelRule{
|
||||
Prefix: "presence-conversation.",
|
||||
Authorize: func(_ context.Context, userID, suffix string) (bool, error) {
|
||||
return suffix == "open-to-user-a" && userID == testUser, nil
|
||||
},
|
||||
}
|
||||
return NewBroadcastingAuthHandler(rt, zap.NewNop(), NotificationsRule(), convRule)
|
||||
}
|
||||
|
||||
// serve posts a form-encoded auth request as userID ("" = unauthenticated).
|
||||
func serve(t *testing.T, h *BroadcastingAuthHandler, userID, socketID, channel string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
form := url.Values{"socket_id": {socketID}, "channel_name": {channel}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/broadcasting/auth", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if userID != "" {
|
||||
req = req.WithContext(middleware.WithUserID(req.Context(), userID))
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.Handle(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestBroadcastingAuth_Authorization(t *testing.T) {
|
||||
h := testAuthHandler(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
userID string
|
||||
channel string
|
||||
want int
|
||||
}{
|
||||
{"own notifications channel", testUser, "private-notifications." + testUser, http.StatusOK},
|
||||
{"another user's notifications", testUser, "private-notifications.user-b", http.StatusForbidden},
|
||||
{"unauthenticated", "", "private-notifications." + testUser, http.StatusUnauthorized},
|
||||
{"unknown channel family", testUser, "private-secrets.42", http.StatusForbidden},
|
||||
{"legacy public channel is not signable", testUser, "notifications." + testUser, http.StatusForbidden},
|
||||
{"empty suffix", testUser, "private-notifications.", http.StatusForbidden},
|
||||
{"presence channel allowed by rule", testUser, "presence-conversation.open-to-user-a", http.StatusOK},
|
||||
{"presence channel denied by rule", testUser, "presence-conversation.someone-elses", http.StatusForbidden},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := serve(t, h, tc.userID, "1234.5678", tc.channel)
|
||||
if rec.Code != tc.want {
|
||||
t.Errorf("status = %d, want %d (body %s)", rec.Code, tc.want, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A prefix match must not be mistaken for authorization: "…user-a-evil" starts
|
||||
// with the victim's id but is a different channel.
|
||||
func TestBroadcastingAuth_SuffixIsComparedExactly(t *testing.T) {
|
||||
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser+"-evil")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastingAuth_SignatureShape(t *testing.T) {
|
||||
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "private-notifications."+testUser)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
Auth string `json:"auth"`
|
||||
ChannelData string `json:"channel_data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(body.Auth, "test-key:") {
|
||||
t.Errorf("auth = %q, want a <key>:<signature> pair", body.Auth)
|
||||
}
|
||||
// channel_data belongs to presence channels only; sending it for a private
|
||||
// channel would make Soketi reject the subscription.
|
||||
if body.ChannelData != "" {
|
||||
t.Errorf("channel_data = %q, want empty for a private channel", body.ChannelData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastingAuth_PresenceReturnsChannelData(t *testing.T) {
|
||||
rec := serve(t, testAuthHandler(t), testUser, "1234.5678", "presence-conversation.open-to-user-a")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
ChannelData string `json:"channel_data"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if !strings.Contains(body.ChannelData, `"user_id":"`+testUser+`"`) {
|
||||
t.Errorf("channel_data = %q, want the caller's user_id", body.ChannelData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastingAuth_AcceptsJSONBody(t *testing.T) {
|
||||
body := `{"socket_id":"1234.5678","channel_name":"private-notifications.` + testUser + `"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/broadcasting/auth", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(middleware.WithUserID(req.Context(), testUser))
|
||||
rec := httptest.NewRecorder()
|
||||
testAuthHandler(t).Handle(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastingAuth_MissingParams(t *testing.T) {
|
||||
h := testAuthHandler(t)
|
||||
if rec := serve(t, h, testUser, "", "private-notifications."+testUser); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("missing socket_id: status = %d, want 400", rec.Code)
|
||||
}
|
||||
if rec := serve(t, h, testUser, "1234.5678", ""); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("missing channel_name: status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// With realtime disabled there is no secret to sign with; the endpoint must
|
||||
// say so rather than hand back an unusable signature.
|
||||
func TestBroadcastingAuth_UnconfiguredRealtime(t *testing.T) {
|
||||
h := NewBroadcastingAuthHandler(nil, zap.NewNop(), NotificationsRule())
|
||||
if rec := serve(t, h, testUser, "1234.5678", "private-notifications."+testUser); rec.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -9,10 +9,33 @@ import (
|
||||
|
||||
type DiscipleHandler struct {
|
||||
repo *repository.DiscipleRepo
|
||||
// users (Postgres) resolves which disciples have a platform account
|
||||
// linked by an admin. Separate store from the disciple records
|
||||
// themselves, hence the second dependency.
|
||||
users *repository.UserRepo
|
||||
}
|
||||
|
||||
func NewDiscipleHandler(repo *repository.DiscipleRepo) *DiscipleHandler {
|
||||
return &DiscipleHandler{repo: repo}
|
||||
func NewDiscipleHandler(repo *repository.DiscipleRepo, users *repository.UserRepo) *DiscipleHandler {
|
||||
return &DiscipleHandler{repo: repo, users: users}
|
||||
}
|
||||
|
||||
// Linked — GET /disciples/linked — every disciple that has a platform account,
|
||||
// as {disciple_id, handle, name, avatar_url}. Public and unpaged: only Śrīla
|
||||
// Prabhupāda's own disciples are ever linked, so the set is tiny and the
|
||||
// /disciples browser fetches it once to highlight the matching cards.
|
||||
func (h *DiscipleHandler) Linked(w http.ResponseWriter, r *http.Request) {
|
||||
if h.users == nil {
|
||||
// Degraded boot without Postgres — an empty set just means "no
|
||||
// highlights", which is a fine reading experience.
|
||||
response.JSON(w, http.StatusOK, []repository.DiscipleLink{})
|
||||
return
|
||||
}
|
||||
links, err := h.users.ListDiscipleLinks(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "failed to fetch linked disciples")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, links)
|
||||
}
|
||||
|
||||
func (h *DiscipleHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// Mine returns the authenticated caller's own feedback submissions,
|
||||
// newest first — each with its status (acknowledged / resolved) and any
|
||||
// admin reply. Registered under RequireAuth, so UserID is always set;
|
||||
// the guard is belt-and-suspenders.
|
||||
func (h *FeedbackHandler) Mine(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
if uid == "" {
|
||||
response.Error(w, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
items, err := h.svc.ListForUser(r.Context(), uid, 50)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load your feedback")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, items)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -203,11 +203,20 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
|
||||
prioritizeTokenPos := true
|
||||
prefix, numTypos := fuzzinessKnobs(exact)
|
||||
searches := make([]api.MultiSearchCollectionParameters, 0, len(collections))
|
||||
// Merge-paginate: every sub-search fetches the first page*limit hits (page 1,
|
||||
// a wide window) so the merged top page*limit is fully covered; we slice the
|
||||
// requested page out after sorting below. Asking each collection for its OWN
|
||||
// page N instead would miss globally-ranked hits and go blank past page 1
|
||||
// (e.g. a sparse query whose few hits all sit in each collection's page 1).
|
||||
window := page * limit
|
||||
if window > 250 { // Typesense per_page ceiling
|
||||
window = 250
|
||||
}
|
||||
for _, col := range collections {
|
||||
queryBy := collectionQueryByFields[col]
|
||||
weights := collectionQueryByWeights[col]
|
||||
perPage := limit
|
||||
pg := page
|
||||
perPage := window
|
||||
pg := 1
|
||||
params := api.MultiSearchCollectionParameters{
|
||||
Collection: col,
|
||||
Q: stringPtr(query),
|
||||
@@ -262,14 +271,18 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
|
||||
return allItems[i].score > allItems[j].score
|
||||
})
|
||||
|
||||
// Paginate the merged results: since each sub-search already returned `limit`
|
||||
// results for the given page, the merged set has at most 9*limit items.
|
||||
// We take the top `limit` after sorting.
|
||||
if len(allItems) > limit {
|
||||
allItems = allItems[:limit]
|
||||
// Slice the requested page out of the merged ranking. Collections hold
|
||||
// disjoint documents, so totalFound (sum of per-collection founds) is the
|
||||
// exact total and start past the end means the page is genuinely empty.
|
||||
start := (page - 1) * limit
|
||||
if start >= len(allItems) {
|
||||
return []searchResultItem{}, totalFound
|
||||
}
|
||||
|
||||
return allItems, totalFound
|
||||
end := start + limit
|
||||
if end > len(allItems) {
|
||||
end = len(allItems)
|
||||
}
|
||||
return allItems[start:end], totalFound
|
||||
}
|
||||
|
||||
// singleSearch searches a single collection.
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
+486
-13
@@ -12,9 +12,11 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 +28,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"`
|
||||
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 +81,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 +89,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 +181,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,9 +235,41 @@ 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)
|
||||
// Keyset path (scroll feed): a `before` cursor pages older posts. Without a
|
||||
// cursor we keep the offset+meta path the profile grid relies on.
|
||||
if beforeAt, beforeID, ok := parseCursor(r); ok {
|
||||
posts, err := h.svc.ListMineKeyset(r.Context(), uid, beforeAt, beforeID, limit)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, posts)
|
||||
return
|
||||
}
|
||||
posts, total, err := h.svc.ListMine(r.Context(), uid, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
@@ -89,6 +278,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 +626,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 +648,242 @@ 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,
|
||||
"bio": u.Bio,
|
||||
"joined_at": u.CreatedAt,
|
||||
"country": u.Country,
|
||||
"post_count": count,
|
||||
"hidden_count": hiddenCount,
|
||||
"follower_count": followers,
|
||||
"following_count": following,
|
||||
"relationship": rel,
|
||||
"public_collections": cols,
|
||||
// True when an admin has linked this account to a record in the
|
||||
// disciples directory — i.e. one of Śrīla Prabhupāda's own initiated
|
||||
// disciples. Drives the badge on the profile header. Only the boolean
|
||||
// is exposed; the underlying Mongo id stays internal.
|
||||
"is_direct_disciple": u.DiscipleID != nil && *u.DiscipleID != "",
|
||||
})
|
||||
}
|
||||
|
||||
// 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).
|
||||
//
|
||||
// Keyset path (scroll feed): a `before` cursor pages older posts. Without a
|
||||
// cursor we keep the offset+meta path the profile grid relies on.
|
||||
if beforeAt, beforeID, ok := parseCursor(r); ok {
|
||||
posts, err := h.posts.ListByUserKeyset(r.Context(), owner.ID, beforeAt, beforeID, limit)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
if err := h.hydrateFeed(r, posts); err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, posts)
|
||||
return
|
||||
}
|
||||
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})
|
||||
}
|
||||
|
||||
// IncomingFollowRequests — GET /follow-requests?limit=&offset=&q=. The caller's
|
||||
// pending incoming follow requests (auth required), for the notifications
|
||||
// "Friend Requests" tab where they're accepted/rejected.
|
||||
func (h *ProfileHandler) IncomingFollowRequests(w http.ResponseWriter, r *http.Request) {
|
||||
uid := middleware.UserID(r.Context())
|
||||
limit, offset := parseLimitOffset(r)
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
users, total, err := h.graph.IncomingRequests(r.Context(), uid, q, limit, offset)
|
||||
if err != nil {
|
||||
mapSocialErr(w, err)
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -440,12 +892,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:
|
||||
@@ -473,6 +930,22 @@ func parseLimitOffset(r *http.Request) (limit, offset int) {
|
||||
return limit, offset
|
||||
}
|
||||
|
||||
// parseCursor reads an optional keyset cursor for the scroll feed: `before` is
|
||||
// the last seen post's created_at (RFC3339Nano) and `before_id` its id. ok is
|
||||
// false when either is missing or unparseable (callers then page by offset).
|
||||
func parseCursor(r *http.Request) (beforeAt time.Time, beforeID string, ok bool) {
|
||||
at := strings.TrimSpace(r.URL.Query().Get("before"))
|
||||
id := strings.TrimSpace(r.URL.Query().Get("before_id"))
|
||||
if at == "" || id == "" {
|
||||
return time.Time{}, "", false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, at)
|
||||
if err != nil {
|
||||
return time.Time{}, "", false
|
||||
}
|
||||
return t, id, true
|
||||
}
|
||||
|
||||
func paginationMeta(total, limit, offset int) *response.Meta {
|
||||
if limit == 0 {
|
||||
limit = 20
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// userIDFromNotifChannel extracts the user id from a per-user notifications
|
||||
// channel, matching both the private name and the legacy public one.
|
||||
//
|
||||
// Because these channels are keyed by user, a `channel_vacated` webhook on one
|
||||
// means that user's *last* connection dropped — i.e. they went offline across
|
||||
// all their tabs — so we can evict their feed seen-set. Both names are matched
|
||||
// for the duration of the private-channel migration: clients running a
|
||||
// service-worker-cached bundle still sit on the legacy channel, and matching
|
||||
// only one name would silently stop evicting for half the users.
|
||||
func userIDFromNotifChannel(channel string) (string, bool) {
|
||||
if uid, ok := strings.CutPrefix(channel, realtime.UserChannelPrefix); ok {
|
||||
return uid, uid != ""
|
||||
}
|
||||
uid, ok := strings.CutPrefix(channel, realtime.LegacyUserChannelPrefix)
|
||||
return uid, ok && uid != ""
|
||||
}
|
||||
|
||||
// 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 := userIDFromNotifChannel(e.Channel); ok {
|
||||
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,31 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
// Both channel names must resolve during the private-channel migration:
|
||||
// matching only one would silently stop evicting feed seen-sets for every
|
||||
// client still on the other name.
|
||||
func TestUserIDFromNotifChannel(t *testing.T) {
|
||||
tests := []struct {
|
||||
channel string
|
||||
wantID string
|
||||
wantOK bool
|
||||
}{
|
||||
{"private-notifications.user-a", "user-a", true},
|
||||
{"notifications.user-a", "user-a", true},
|
||||
{"private-notifications.", "", false},
|
||||
{"notifications.", "", false},
|
||||
{"presence-conversation.c1", "", false},
|
||||
{"", "", false},
|
||||
// Guards against a naive suffix split: the private name must not be
|
||||
// mistaken for the legacy one and yield a mangled id.
|
||||
{"private-notifications.notifications.x", "notifications.x", true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
gotID, gotOK := userIDFromNotifChannel(tc.channel)
|
||||
if gotOK != tc.wantOK || (tc.wantOK && gotID != tc.wantID) {
|
||||
t.Errorf("userIDFromNotifChannel(%q) = (%q, %v), want (%q, %v)",
|
||||
tc.channel, gotID, gotOK, tc.wantID, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
// 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 is READ-ONLY: feedback acknowledge/resolve actions live in the
|
||||
// authenticated admin panel, never on this public page.
|
||||
//
|
||||
// PRIVACY: because the page is public, it must never render personal contact
|
||||
// details. Anonymous submitters' email addresses are shown as "reply address
|
||||
// on file" only — and FeedbackRepo.Recent doesn't even SELECT the column, so
|
||||
// the address isn't in memory here to leak by accident. Anything added to this
|
||||
// template needs the same scrutiny; the admin panel is the place for PII.
|
||||
type StatsHandler struct {
|
||||
stats *repository.StatsRepo
|
||||
feedback *repository.FeedbackRepo
|
||||
}
|
||||
|
||||
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo) *StatsHandler {
|
||||
return &StatsHandler{stats: stats, feedback: feedback}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
.pending-badge { display: inline-flex; align-items: center; font-size: 0.78rem; font-weight: 600; color: #999 }
|
||||
.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 .HasContactEmail}}
|
||||
anonymous · reply address on file
|
||||
{{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}}
|
||||
<span class="pending-badge">Pending</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="empty">No feedback yet.</p>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>`
|
||||
@@ -0,0 +1,46 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
// Executes the public /stats template against representative rows and asserts
|
||||
// no submitter email can appear in the output.
|
||||
func TestStatsTemplateHidesEmails(t *testing.T) {
|
||||
now := time.Now()
|
||||
secret := "leak-me@example.com"
|
||||
handle, name := "gopal", "Gopal Das"
|
||||
rows := []model.Feedback{
|
||||
// anonymous WITH a reply address on file
|
||||
{ID: "1", Category: "bug", Message: "anon with email", CreatedAt: now,
|
||||
HasContactEmail: true, ContactEmail: &secret},
|
||||
// anonymous with no address at all
|
||||
{ID: "2", Category: "general", Message: "anon no email", CreatedAt: now},
|
||||
// authed submitter
|
||||
{ID: "3", Category: "feature", Message: "authed", CreatedAt: now,
|
||||
AuthorHandle: &handle, AuthorName: &name, AcknowledgedAt: &now, ResolvedAt: &now},
|
||||
}
|
||||
var sb strings.Builder
|
||||
err := statsTpl.Execute(&sb, map[string]any{
|
||||
"Stats": &repository.StatsSnapshot{Users: 1},
|
||||
"Feedback": rows,
|
||||
"GeneratedAt": "now",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("template execute failed: %v", err)
|
||||
}
|
||||
out := sb.String()
|
||||
if strings.Contains(out, secret) || strings.Contains(out, "@example.com") {
|
||||
t.Errorf("submitter email leaked into public /stats output")
|
||||
}
|
||||
for _, want := range []string{"reply address on file", "@gopal", "anon no email"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected %q in output", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
@@ -70,6 +71,29 @@ func (h *TranscriptionHandler) GetByType(w http.ResponseWriter, r *http.Request)
|
||||
response.JSON(w, http.StatusOK, docs)
|
||||
}
|
||||
|
||||
func (h *TranscriptionHandler) GetBooks(w http.ResponseWriter, r *http.Request) {
|
||||
docs, err := h.repo.GetBooks(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "failed to fetch books")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, docs)
|
||||
}
|
||||
|
||||
func (h *TranscriptionHandler) GetByBook(w http.ResponseWriter, r *http.Request) {
|
||||
code := strings.ToUpper(chi.URLParam(r, "book"))
|
||||
if h.repo.BookTitle(code) == "" {
|
||||
response.Error(w, http.StatusNotFound, "unknown book")
|
||||
return
|
||||
}
|
||||
docs, err := h.repo.GetByBook(r.Context(), code)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "failed to fetch transcriptions")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, docs)
|
||||
}
|
||||
|
||||
func (h *TranscriptionHandler) GetYears(w http.ResponseWriter, r *http.Request) {
|
||||
docs, err := h.repo.GetYears(r.Context())
|
||||
if err != nil {
|
||||
|
||||
+139
-2
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||
@@ -29,9 +30,21 @@ type updateProfileReq struct {
|
||||
Name *string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Handle *string `json:"handle"`
|
||||
Bio *string `json:"bio"`
|
||||
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 +54,20 @@ 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,
|
||||
Bio: req.Bio,
|
||||
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 +79,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,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||
)
|
||||
|
||||
// VaniHandler serves the public "Vāṇī of the Day" pick for the home page.
|
||||
type VaniHandler struct {
|
||||
svc *service.VaniService
|
||||
}
|
||||
|
||||
func NewVaniHandler(svc *service.VaniService) *VaniHandler {
|
||||
return &VaniHandler{svc: svc}
|
||||
}
|
||||
|
||||
// Today — GET /vani/today. Public; the same pick is returned to everyone for
|
||||
// the current UTC day. Responds with `data: null` if there's no content.
|
||||
func (h *VaniHandler) Today(w http.ResponseWriter, r *http.Request) {
|
||||
payload, err := h.svc.Today(r.Context())
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "could not load vāṇī of the day")
|
||||
return
|
||||
}
|
||||
response.JSON(w, http.StatusOK, payload)
|
||||
}
|
||||
@@ -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,46 @@ 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// WithUserID returns a context carrying userID, as RequireAuth would set it.
|
||||
// The exported counterpart to UserID: it lets handlers be exercised in tests
|
||||
// without standing up a JWT issuer and a user repository.
|
||||
func WithUserID(ctx context.Context, userID string) context.Context {
|
||||
return context.WithValue(ctx, userIDKey, userID)
|
||||
}
|
||||
|
||||
// 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,47 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RealIP rewrites r.RemoteAddr to the genuine client IP for a deployment where
|
||||
// a SINGLE trusted reverse proxy is the edge and nothing sits in front of it
|
||||
// (here: Dokploy's Traefik — DNS points straight at it).
|
||||
//
|
||||
// Such an edge APPENDS the TCP peer it actually saw to the right of
|
||||
// X-Forwarded-For, so the LAST entry is the real client and can't be spoofed:
|
||||
// a client may only prepend left-hand entries, which we ignore. This is the
|
||||
// opposite of chi's RealIP, which trusts the left-most X-Forwarded-For entry
|
||||
// (and X-Real-IP) and is therefore spoofable behind an appending proxy —
|
||||
// yielding the wrong GeoIP country and a forgeable rate-limit key.
|
||||
//
|
||||
// Downstream consumers (geoip.ClientIP, the rate limiter, the request logger)
|
||||
// all read r.RemoteAddr, so fixing it here fixes them all. When no forwarded
|
||||
// header is present (e.g. a direct hit on the container), RemoteAddr is left
|
||||
// untouched.
|
||||
func RealIP(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ip := clientIPFromEdge(r); ip != "" {
|
||||
r.RemoteAddr = ip
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func clientIPFromEdge(r *http.Request) string {
|
||||
// Right-most X-Forwarded-For entry = the IP the trusted edge observed.
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
last := strings.TrimSpace(parts[len(parts)-1])
|
||||
if net.ParseIP(last) != nil {
|
||||
return last
|
||||
}
|
||||
}
|
||||
// Fallback: the edge also overwrites X-Real-IP from the connection.
|
||||
if xr := strings.TrimSpace(r.Header.Get("X-Real-Ip")); net.ParseIP(xr) != nil {
|
||||
return xr
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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"`
|
||||
PostCount int `json:"post_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Connected auth methods. HasPassword means email+password login is set up;
|
||||
// Providers lists linked OAuth providers (e.g. "google", "facebook").
|
||||
HasPassword bool `json:"has_password"`
|
||||
Providers []string `json:"providers"`
|
||||
|
||||
// Effective save limits in force for this user (plan default merged with any
|
||||
// admin override); -1 means unlimited. The admin UI pre-fills the "Edit
|
||||
// limits" modal with these.
|
||||
BookmarkLimit int `json:"bookmark_limit"`
|
||||
PublicCollectionLimit int `json:"public_collection_limit"`
|
||||
PrivateCollectionLimit int `json:"private_collection_limit"`
|
||||
|
||||
// Direct-disciple link. DiscipleID is the raw Mongo ObjectID stored on the
|
||||
// users row; DiscipleName is hydrated from Mongo by the admin service so the
|
||||
// panel can show who the account is linked to (nil when unlinked, or when
|
||||
// the id no longer resolves to a disciple document).
|
||||
DiscipleID *string `json:"disciple_id"`
|
||||
DiscipleName *string `json:"disciple_name"`
|
||||
}
|
||||
|
||||
// AdminDisciple is one option in the admin panel's disciple picker — enough to
|
||||
// disambiguate similarly-named disciples, plus the handle of the account that
|
||||
// already claims it (nil when free to link).
|
||||
type AdminDisciple struct {
|
||||
ID string `json:"id"`
|
||||
InitiatedName string `json:"initiated_name"`
|
||||
Suffix string `json:"suffix"`
|
||||
InitiationDate *time.Time `json:"initiation_date"`
|
||||
InitiationPlace *string `json:"initiation_place"`
|
||||
// LinkedHandle is the @handle of the user already linked to this disciple,
|
||||
// so the picker can grey the row out instead of failing on submit.
|
||||
LinkedHandle *string `json:"linked_handle"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_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"`
|
||||
|
||||
// ResolutionNote is the admin's note left when resolving (nil while open).
|
||||
ResolutionNote *string `json:"resolution_note"`
|
||||
// TargetRemoved is true when the reported content is currently removed —
|
||||
// a soft-deleted post/comment, or a banned account — so the UI can offer
|
||||
// "Undo" instead of "Delete"/"Ban".
|
||||
TargetRemoved bool `json:"target_removed"`
|
||||
|
||||
// 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,16 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// AudioProgress is one persisted resume point for the floating audio
|
||||
// player — the last-known playback position of a single audio file for a
|
||||
// user. Key is the track URL with query/hash stripped (the stable file
|
||||
// identity, owned by the frontend). Position/Duration mirror the
|
||||
// HTMLAudioElement's currentTime/duration in seconds. UpdatedAt is set by
|
||||
// the client so multi-device writes resolve newest-wins on upsert.
|
||||
type AudioProgress struct {
|
||||
Key string `json:"key"`
|
||||
Position float64 `json:"position"`
|
||||
Duration *float64 `json:"duration,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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"`
|
||||
|
||||
// ReplyMessage is the admin's written reply to the submitter, sent
|
||||
// (and stored) from the admin panel. NULL until an admin replies.
|
||||
// RepliedAt stamps when it was sent. Surfaced to the submitter on the
|
||||
// "My feedback" page as the team's response.
|
||||
ReplyMessage *string `json:"reply_message,omitempty"`
|
||||
RepliedAt *time.Time `json:"replied_at,omitempty"`
|
||||
|
||||
// HasContactEmail reports *whether* an anonymous submitter left a reply-to
|
||||
// address, without carrying the address itself. Populated only by
|
||||
// Recent(), whose sole consumer is the PUBLIC /stats page — that page has
|
||||
// to distinguish "anonymous, but we can reply" from "anonymous dead end"
|
||||
// while never handling the email. Admins see the real address in the
|
||||
// admin panel, which is auth-gated.
|
||||
HasContactEmail bool `json:"-"`
|
||||
|
||||
AuthorHandle *string `json:"author_handle,omitempty"`
|
||||
AuthorName *string `json:"author_name,omitempty"`
|
||||
AuthorEmail *string `json:"-"` // server-side only; never serialised
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package model
|
||||
|
||||
// Plan-default save limits (-1 = unlimited). Per-user override columns on the
|
||||
// users row take precedence when non-nil; admins set them from the panel.
|
||||
const (
|
||||
planPremium = "premium"
|
||||
|
||||
defaultFreeBookmarkLimit = 1
|
||||
defaultPremiumBookmarkLimit = 5
|
||||
defaultFreeCollectionLimit = 5 // per visibility; premium = unlimited
|
||||
)
|
||||
|
||||
// UserLimits are the effective per-resource caps in force for a user (-1 means
|
||||
// unlimited). Surfaced on the self user payload so the app can render
|
||||
// "X / Y used" and gate create actions without hardcoding the numbers.
|
||||
type UserLimits struct {
|
||||
Bookmarks int `json:"bookmarks"`
|
||||
PublicCollections int `json:"public_collections"`
|
||||
PrivateCollections int `json:"private_collections"`
|
||||
}
|
||||
|
||||
// ResolveLimits computes the effective caps from a plan plus optional per-user
|
||||
// overrides (nil = use the plan default). Shared by the self payload and the
|
||||
// admin user listing so both agree on what's in force.
|
||||
func ResolveLimits(plan string, bookmark, publicCollection, privateCollection *int) UserLimits {
|
||||
premium := plan == planPremium
|
||||
|
||||
bm := defaultFreeBookmarkLimit
|
||||
if premium {
|
||||
bm = defaultPremiumBookmarkLimit
|
||||
}
|
||||
pub, priv := defaultFreeCollectionLimit, defaultFreeCollectionLimit
|
||||
if premium {
|
||||
pub, priv = -1, -1
|
||||
}
|
||||
if bookmark != nil {
|
||||
bm = *bookmark
|
||||
}
|
||||
if publicCollection != nil {
|
||||
pub = *publicCollection
|
||||
}
|
||||
if privateCollection != nil {
|
||||
priv = *privateCollection
|
||||
}
|
||||
return UserLimits{Bookmarks: bm, PublicCollections: pub, PrivateCollections: priv}
|
||||
}
|
||||
|
||||
// EffectiveLimits resolves this user's caps (plan default + any overrides).
|
||||
func (u *User) EffectiveLimits() UserLimits {
|
||||
return ResolveLimits(u.Plan, u.BookmarkLimit, u.PublicCollectionLimit, u.PrivateCollectionLimit)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// AdminShortURL is a row in the admin short-URL table (a projection of
|
||||
// shortened_urls served only to admins).
|
||||
type AdminShortURL struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
LongURL string `json:"long_url"`
|
||||
ContentType string `json:"content_type"`
|
||||
ClickCount int64 `json:"click_count"`
|
||||
HumanClickCount int64 `json:"human_click_count"`
|
||||
Disabled bool `json:"disabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ShortURLRangeStat is the (created, clicks) pair for one time window.
|
||||
type ShortURLRangeStat struct {
|
||||
Created int64 `json:"created"` // short URLs created in the window
|
||||
Clicks int64 `json:"clicks"` // clicks recorded in the window
|
||||
}
|
||||
|
||||
// ShortURLStats is the admin dashboard's short-URL summary: fixed today/week/
|
||||
// month windows, all-time totals, and an optional custom range.
|
||||
type ShortURLStats struct {
|
||||
Today ShortURLRangeStat `json:"today"`
|
||||
Week ShortURLRangeStat `json:"week"`
|
||||
Month ShortURLRangeStat `json:"month"`
|
||||
TotalURLs int64 `json:"total_urls"`
|
||||
TotalClicks int64 `json:"total_clicks"`
|
||||
Range *ShortURLRangeStat `json:"range,omitempty"` // present only when from/to given
|
||||
}
|
||||
+205
-3
@@ -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,92 @@ 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"`
|
||||
|
||||
// ViewerFollowRequested: the caller has a pending (unaccepted) follow
|
||||
// request to this post's author. Drives the "Requested" feed affordance.
|
||||
ViewerFollowRequested bool `json:"viewer_follow_requested"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -59,10 +146,125 @@ type CollectionRecord struct {
|
||||
// PublicProfile is what /u/{handle} returns — the parts of a User safe to
|
||||
// expose to anyone, plus aggregate counts and the user's posts.
|
||||
type PublicProfile struct {
|
||||
Handle string `json:"handle"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
Handle string `json:"handle"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
// Bio is plain text; `@handle` mentions are linkified at render time.
|
||||
Bio *string `json:"bio"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
PostCount int `json:"post_count"`
|
||||
BookmarkCount int `json:"-"` // never serialized — private
|
||||
// IsDirectDisciple marks an account an admin has linked to the disciples
|
||||
// directory — one of Śrīla Prabhupāda's own initiated disciples.
|
||||
IsDirectDisciple bool `json:"is_direct_disciple"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
+120
-4
@@ -1,28 +1,124 @@
|
||||
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"`
|
||||
|
||||
// DiscipleID links this account to a row in the Mongo `disciples`
|
||||
// collection (ObjectID hex) when the account belongs to one of Śrīla
|
||||
// Prabhupāda's own initiated disciples. Admin-set only — never
|
||||
// self-serve — and unique across users, so one disciple maps to at most
|
||||
// one account. nil for everybody else.
|
||||
//
|
||||
// Tagged `json:"-"`: the raw cross-store id is an internal detail. The
|
||||
// public profile exposes only the derived `is_direct_disciple` flag.
|
||||
DiscipleID *string `json:"-"`
|
||||
|
||||
// Bio is a short self-introduction (max 200 characters, enforced in the
|
||||
// service by rune count) shown on the profile header. Stored as PLAIN
|
||||
// TEXT — `@handle` mentions are kept verbatim and linkified by the
|
||||
// frontend at render time, so it must never be injected as HTML.
|
||||
// nil when unset.
|
||||
Bio *string `json:"bio"`
|
||||
|
||||
// --- 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"`
|
||||
|
||||
// Per-user save-limit overrides (admin-settable; nil = use the plan
|
||||
// default). Never serialized raw — surfaced only via the derived `limits`
|
||||
// object in MarshalJSON so the app can render/enforce the right caps.
|
||||
BookmarkLimit *int `json:"-"`
|
||||
PublicCollectionLimit *int `json:"-"`
|
||||
PrivateCollectionLimit *int `json:"-"`
|
||||
}
|
||||
|
||||
// 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. `is_direct_disciple` is
|
||||
// likewise derived — the raw cross-store DiscipleID stays server-side while
|
||||
// the frontend gets the one bit it needs to render the badge.
|
||||
func (u User) MarshalJSON() ([]byte, error) {
|
||||
type alias User
|
||||
return json.Marshal(&struct {
|
||||
alias
|
||||
HasPassword bool `json:"has_password"`
|
||||
IsDirectDisciple bool `json:"is_direct_disciple"`
|
||||
Limits UserLimits `json:"limits"`
|
||||
}{
|
||||
alias: alias(u),
|
||||
HasPassword: u.PasswordHash != nil,
|
||||
IsDirectDisciple: u.DiscipleID != nil && *u.DiscipleID != "",
|
||||
Limits: u.EffectiveLimits(),
|
||||
})
|
||||
}
|
||||
|
||||
// AuthToken represents a row in the `auth_tokens` table.
|
||||
@@ -40,8 +136,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,25 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// VaniPayload is the prebuilt display blob for the "Vāṇī of the Day" CTA. It's
|
||||
// owned by the frontend's rendering needs and stored verbatim as JSONB, so the
|
||||
// home page can render without re-reading Mongo.
|
||||
type VaniPayload struct {
|
||||
Kind string `json:"kind"` // "verse" | "prose" | "transcript"
|
||||
Label string `json:"label"` // "Verse" | "Chapter" | "Lecture" | "Conversation"
|
||||
Title string `json:"title"` // reference / chapter title / lecture name
|
||||
Subtitle string `json:"subtitle"` // short excerpt or date · place
|
||||
Href string `json:"href"` // in-app path to open the item
|
||||
BookTitle string `json:"book_title,omitempty"` // e.g. "Bhagavad-gītā As It Is"
|
||||
Date string `json:"date,omitempty"` // transcript date, for the "Lecture • DATE" line
|
||||
}
|
||||
|
||||
// VaniPick is one persisted day's choice. RefID is `<kind>:<mongo _id hex>` and
|
||||
// is what the 90-day no-repeat rule dedupes on.
|
||||
type VaniPick struct {
|
||||
Day time.Time
|
||||
Kind string
|
||||
RefID string
|
||||
Payload VaniPayload
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
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 u.id, u.email, u.handle, u.name, u.avatar_url, u.role, u.plan,
|
||||
u.email_verified_at, u.banned_at,
|
||||
(SELECT COUNT(*) FROM posts p WHERE p.user_id = u.id AND p.deleted_at IS NULL) AS post_count,
|
||||
u.created_at,
|
||||
(u.password_hash IS NOT NULL) AS has_password,
|
||||
(SELECT COALESCE(array_agg(oa.provider ORDER BY oa.provider), ARRAY[]::text[])
|
||||
FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers,
|
||||
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
|
||||
u.disciple_id,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM users u
|
||||
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
|
||||
OR COALESCE(u.email, '') ILIKE '%'||$1||'%')
|
||||
ORDER BY u.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
|
||||
var bookmarkLimit, publicCollLimit, privateCollLimit *int
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Handle, &u.Name, &u.AvatarURL,
|
||||
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
|
||||
&u.HasPassword, &u.Providers,
|
||||
&bookmarkLimit, &publicCollLimit, &privateCollLimit,
|
||||
&u.DiscipleID, &total); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan admin user: %w", err)
|
||||
}
|
||||
// Resolve plan default + overrides so the panel shows what's in force.
|
||||
lim := model.ResolveLimits(u.Plan, bookmarkLimit, publicCollLimit, privateCollLimit)
|
||||
u.BookmarkLimit = lim.Bookmarks
|
||||
u.PublicCollectionLimit = lim.PublicCollections
|
||||
u.PrivateCollectionLimit = lim.PrivateCollections
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// ErrDiscipleClaimed is returned by LinkDisciple when another account already
|
||||
// holds the requested disciple — the partial unique index on disciple_id makes
|
||||
// the one-to-one rule a database guarantee rather than a check-then-write race.
|
||||
var ErrDiscipleClaimed = errors.New("disciple already linked to another user")
|
||||
|
||||
// LinkDisciple points a user at a disciple record in Mongo (ObjectID hex).
|
||||
// Re-linking simply overwrites any previous value, so "change the link" needs
|
||||
// no separate unlink step. Returns ErrDiscipleClaimed when a different account
|
||||
// already claims that disciple.
|
||||
//
|
||||
// `previous` is the link the row held *before* this call (nil when it had
|
||||
// none), which is what lets the caller send the welcome email only on the
|
||||
// first link — correcting a mis-link shouldn't welcome the devotee twice. The
|
||||
// FROM subquery snapshots the old value in the same statement, so there's no
|
||||
// read-then-write race.
|
||||
func (r *AdminRepo) LinkDisciple(ctx context.Context, userID, discipleID string) (found bool, previous *string, err error) {
|
||||
err = r.pg.Pool.QueryRow(ctx, `
|
||||
UPDATE users u
|
||||
SET disciple_id = $2,
|
||||
updated_at = now()
|
||||
FROM (SELECT id, disciple_id AS old FROM users WHERE id = $1) prev
|
||||
WHERE u.id = prev.id
|
||||
RETURNING prev.old`, userID, discipleID).Scan(&previous)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
if IsUniqueViolation(err) {
|
||||
return false, nil, ErrDiscipleClaimed
|
||||
}
|
||||
return false, nil, fmt.Errorf("admin link disciple: %w", err)
|
||||
}
|
||||
return true, previous, nil
|
||||
}
|
||||
|
||||
// ClaimDiscipleAnnouncement records that this account's arrival has been
|
||||
// announced to the community, returning true only for the caller that actually
|
||||
// inserted the row.
|
||||
//
|
||||
// This is the whole idempotency guard for the broadcast: announcing writes a
|
||||
// notification to every user, so it must fire at most once per devotee. Doing
|
||||
// it as INSERT … ON CONFLICT DO NOTHING makes the check atomic — a
|
||||
// double-clicked button, a retried request, or two admins acting at once still
|
||||
// yields exactly one announcement.
|
||||
func (r *AdminRepo) ClaimDiscipleAnnouncement(ctx context.Context, userID, discipleID string) (bool, error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
INSERT INTO disciple_announcements (user_id, disciple_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO NOTHING`, userID, discipleID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("claim disciple announcement: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// UnlinkDisciple clears a user's disciple link. Idempotent: returns true as
|
||||
// long as the user exists, whether or not a link was present.
|
||||
func (r *AdminRepo) UnlinkDisciple(ctx context.Context, userID string) (bool, error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET disciple_id = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = $1`, userID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("admin unlink disciple: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// SetUserLimits writes the per-user save-limit overrides (nil clears one back
|
||||
// to the plan default). Returns false if the id doesn't exist.
|
||||
func (r *AdminRepo) SetUserLimits(ctx context.Context, userID string, bookmark, publicCollection, privateCollection *int) (bool, error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET bookmark_limit = $2,
|
||||
public_collection_limit = $3,
|
||||
private_collection_limit = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1`, userID, bookmark, publicCollection, privateCollection)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("admin set user limits: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// 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.acknowledged_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.AcknowledgedAt, &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 and stamps replied_at. It
|
||||
// does NOT change resolved_at — replying and resolving are independent actions
|
||||
// (resolve via ResolveFeedback). 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()
|
||||
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,
|
||||
r.resolution_note, COALESCE(p.deleted_at IS NOT NULL, false),
|
||||
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,
|
||||
r.resolution_note, COALESCE(c.deleted_at IS NOT NULL, false),
|
||||
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,
|
||||
r.resolution_note, COALESCE(tu.banned_at IS NOT NULL, false),
|
||||
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,
|
||||
&rep.ResolutionNote, &rep.TargetRemoved, &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() and stores the admin's note 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, note 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(), resolution_note = $2 WHERE id = $1 AND resolved_at IS NULL`, table),
|
||||
id, note,
|
||||
)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// ListShortURLs returns a page of shortened URLs filtered by an optional query
|
||||
// (matched against code or target URL), newest first, with the total count.
|
||||
func (r *AdminRepo) ListShortURLs(ctx context.Context, q string, limit, offset int) ([]model.AdminShortURL, int, error) {
|
||||
limit = clampLimit(limit)
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT id, code, long_url, content_type, click_count, human_click_count,
|
||||
disabled, created_at,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM shortened_urls
|
||||
WHERE ($1 = '' OR code ILIKE '%'||$1||'%' OR long_url ILIKE '%'||$1||'%')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("admin list short urls: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []model.AdminShortURL{}
|
||||
total := 0
|
||||
for rows.Next() {
|
||||
var s model.AdminShortURL
|
||||
if err := rows.Scan(&s.ID, &s.Code, &s.LongURL, &s.ContentType,
|
||||
&s.ClickCount, &s.HumanClickCount, &s.Disabled, &s.CreatedAt, &total); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan admin short url: %w", err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// ShortURLStats computes created + click counts for the three fixed windows
|
||||
// (thresholds passed in), the all-time totals, and — when from/to is given —
|
||||
// a custom range (from inclusive, to exclusive). "Created" comes from
|
||||
// shortened_urls.created_at; "clicks" from the shortened_url_clicks log.
|
||||
func (r *AdminRepo) ShortURLStats(
|
||||
ctx context.Context,
|
||||
todayStart, weekStart, monthStart time.Time,
|
||||
from, to *time.Time,
|
||||
) (*model.ShortURLStats, error) {
|
||||
var s model.ShortURLStats
|
||||
|
||||
if err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*),
|
||||
COUNT(*) FILTER (WHERE created_at >= $1),
|
||||
COUNT(*) FILTER (WHERE created_at >= $2),
|
||||
COUNT(*) FILTER (WHERE created_at >= $3)
|
||||
FROM shortened_urls`, todayStart, weekStart, monthStart).
|
||||
Scan(&s.TotalURLs, &s.Today.Created, &s.Week.Created, &s.Month.Created); err != nil {
|
||||
return nil, fmt.Errorf("short url created stats: %w", err)
|
||||
}
|
||||
|
||||
if err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*),
|
||||
COUNT(*) FILTER (WHERE clicked_at >= $1),
|
||||
COUNT(*) FILTER (WHERE clicked_at >= $2),
|
||||
COUNT(*) FILTER (WHERE clicked_at >= $3)
|
||||
FROM shortened_url_clicks`, todayStart, weekStart, monthStart).
|
||||
Scan(&s.TotalClicks, &s.Today.Clicks, &s.Week.Clicks, &s.Month.Clicks); err != nil {
|
||||
return nil, fmt.Errorf("short url click stats: %w", err)
|
||||
}
|
||||
|
||||
if from != nil && to != nil {
|
||||
var rng model.ShortURLRangeStat
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM shortened_urls WHERE created_at >= $1 AND created_at < $2`,
|
||||
*from, *to).Scan(&rng.Created); err != nil {
|
||||
return nil, fmt.Errorf("short url range created: %w", err)
|
||||
}
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM shortened_url_clicks WHERE clicked_at >= $1 AND clicked_at < $2`,
|
||||
*from, *to).Scan(&rng.Clicks); err != nil {
|
||||
return nil, fmt.Errorf("short url range clicks: %w", err)
|
||||
}
|
||||
s.Range = &rng
|
||||
}
|
||||
|
||||
return &s, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// AudioProgressRepo wraps Postgres access for audio_progress — the audio
|
||||
// player's per-file resume points. One row per (user, audio_key).
|
||||
type AudioProgressRepo struct{ pg *Postgres }
|
||||
|
||||
func NewAudioProgressRepo(pg *Postgres) *AudioProgressRepo {
|
||||
return &AudioProgressRepo{pg: pg}
|
||||
}
|
||||
|
||||
// ListByUser returns all of the user's saved resume points, newest first.
|
||||
// Used to hydrate the player's in-memory map on sign-in.
|
||||
func (r *AudioProgressRepo) ListByUser(ctx context.Context, userID string) ([]model.AudioProgress, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT audio_key, position_seconds, duration_seconds, updated_at
|
||||
FROM audio_progress
|
||||
WHERE user_id = $1
|
||||
ORDER BY updated_at DESC`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list audio progress: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []model.AudioProgress
|
||||
for rows.Next() {
|
||||
var p model.AudioProgress
|
||||
if err := rows.Scan(&p.Key, &p.Position, &p.Duration, &p.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan audio progress: %w", err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Upsert writes a batch of resume points. Each row is inserted or updated
|
||||
// only when the incoming updated_at is at least as new as the stored one
|
||||
// (newest-wins), so concurrent tabs/devices and the on-login sync of
|
||||
// anonymous IndexedDB entries never clobber a fresher position.
|
||||
func (r *AudioProgressRepo) Upsert(ctx context.Context, userID string, items []model.AudioProgress) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
batch := &pgx.Batch{}
|
||||
for _, it := range items {
|
||||
batch.Queue(`
|
||||
INSERT INTO audio_progress
|
||||
(user_id, audio_key, position_seconds, duration_seconds, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id, audio_key) DO UPDATE
|
||||
SET position_seconds = EXCLUDED.position_seconds,
|
||||
duration_seconds = EXCLUDED.duration_seconds,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE EXCLUDED.updated_at >= audio_progress.updated_at`,
|
||||
userID, it.Key, it.Position, it.Duration, it.UpdatedAt)
|
||||
}
|
||||
br := r.pg.Pool.SendBatch(ctx, batch)
|
||||
defer br.Close()
|
||||
for range items {
|
||||
if _, err := br.Exec(); err != nil {
|
||||
return fmt.Errorf("upsert audio progress: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a single resume point (called when a track plays to
|
||||
// completion so it restarts from the beginning next time).
|
||||
func (r *AudioProgressRepo) Delete(ctx context.Context, userID, key string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx,
|
||||
`DELETE FROM audio_progress WHERE user_id = $1 AND audio_key = $2`,
|
||||
userID, key,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete audio progress: %w", err)
|
||||
}
|
||||
return 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
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -88,6 +89,60 @@ func (r *DiscipleRepo) GetByLetterAndSuffix(ctx context.Context, letter *string,
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// FindByIDs returns summary docs for the given ObjectID hex strings (order
|
||||
// unspecified). Ids that aren't valid ObjectIDs are skipped rather than
|
||||
// failing the batch — the ids come from a Postgres column with no FK, so a
|
||||
// malformed or stale value must degrade gracefully. An empty/all-invalid
|
||||
// input short-circuits without hitting Mongo.
|
||||
//
|
||||
// Used to hydrate disciple names onto admin user rows: the user→disciple link
|
||||
// lives in Postgres while the disciple record lives here.
|
||||
func (r *DiscipleRepo) FindByIDs(ctx context.Context, ids []string) ([]DiscipleSummaryDoc, error) {
|
||||
oids := make([]bson.ObjectID, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if oid, err := bson.ObjectIDFromHex(id); err == nil {
|
||||
oids = append(oids, oid)
|
||||
}
|
||||
}
|
||||
if len(oids) == 0 {
|
||||
return []DiscipleSummaryDoc{}, nil
|
||||
}
|
||||
cursor, err := r.col.Find(ctx,
|
||||
bson.M{"_id": bson.M{"$in": oids}},
|
||||
options.Find().SetProjection(discipleSummaryProjection),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
var results []DiscipleSummaryDoc
|
||||
if err := cursor.All(ctx, &results); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// FindByID returns one disciple summary, or nil when the id is malformed or
|
||||
// no such document exists. Callers use it to validate a link target before
|
||||
// writing it to Postgres.
|
||||
func (r *DiscipleRepo) FindByID(ctx context.Context, id string) (*DiscipleSummaryDoc, error) {
|
||||
oid, err := bson.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return nil, nil // malformed id — "not found", not an error
|
||||
}
|
||||
var doc DiscipleSummaryDoc
|
||||
err = r.col.FindOne(ctx, bson.M{"_id": oid},
|
||||
options.FindOne().SetProjection(discipleSummaryProjection),
|
||||
).Decode(&doc)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
func (r *DiscipleRepo) Count(ctx context.Context) (int64, error) {
|
||||
return r.col.CountDocuments(ctx, bson.M{})
|
||||
}
|
||||
|
||||
@@ -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,160 @@
|
||||
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
|
||||
}
|
||||
|
||||
// ListByUser returns a single user's own feedback submissions, newest
|
||||
// first, with status timestamps and any admin reply. Backs the "My
|
||||
// feedback" page under /settings. No author joins — the caller IS the
|
||||
// author, so handle/name/email aren't needed here. Bounded limit keeps
|
||||
// the query cheap.
|
||||
func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int) ([]model.Feedback, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT id, user_id, category, message, contact_email, created_at,
|
||||
acknowledged_at, resolved_at, replied_at, reply_message
|
||||
FROM feedback
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`, userID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query user 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.RepliedAt, &f.ReplyMessage); err != nil {
|
||||
return nil, fmt.Errorf("scan user feedback: %w", err)
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// 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).
|
||||
//
|
||||
// PRIVACY: /stats is a public page, so this query deliberately does NOT select
|
||||
// contact_email — only a boolean saying whether one exists. Submitters' email
|
||||
// addresses must never reach that template, and not fetching them makes that
|
||||
// impossible rather than merely unlikely. Callers that legitimately need the
|
||||
// address (the reply/thank-you emails) use FindByID instead.
|
||||
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.created_at,
|
||||
(f.contact_email IS NOT NULL AND f.contact_email <> '') AS has_contact_email,
|
||||
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.CreatedAt,
|
||||
&f.HasContactEmail,
|
||||
&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,232 @@ 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 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,
|
||||
).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count posts: %w", err)
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
return r.ListByUser(ctx, userID, 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)
|
||||
// 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)
|
||||
}
|
||||
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
|
||||
|
||||
// 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 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 mentioning posts: %w", err)
|
||||
}
|
||||
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
|
||||
}
|
||||
// id DESC tiebreak must match ListByUserKeyset so the scroll feed's first
|
||||
// (offset) page and its keyset continuation agree on the order of
|
||||
// same-created_at posts — otherwise the keyset page re-returns already-shown
|
||||
// rows, the client's cursor never advances, and it loops forever.
|
||||
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 DESC
|
||||
LIMIT $2 OFFSET $3`
|
||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||
}
|
||||
|
||||
// ListByUserKeyset returns the user's visible posts older than the cursor
|
||||
// (created_at, id), newest-first — keyset pagination for the Instagram-style
|
||||
// scroll feed. A zero beforeAt returns the newest page. No total is computed
|
||||
// (infinite scroll infers "more" from a full page, like FeedForViewer). The
|
||||
// (created_at, id) DESC ordering matches the tuple comparison so paging never
|
||||
// skips or repeats a row even when timestamps tie.
|
||||
func (r *PostRepo) ListByUserKeyset(ctx context.Context, userID string, beforeAt time.Time, beforeID string, limit int) ([]model.Post, error) {
|
||||
base := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||
WHERE p.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL`
|
||||
if beforeAt.IsZero() {
|
||||
q := base + `
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
LIMIT $2`
|
||||
posts, _, err := r.collectPosts(ctx, 0, q, userID, limit)
|
||||
return posts, err
|
||||
}
|
||||
q := base + `
|
||||
AND (p.created_at < $2 OR (p.created_at = $2 AND p.id < $3))
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
LIMIT $4`
|
||||
posts, _, err := r.collectPosts(ctx, 0, q, userID, beforeAt, beforeID, limit)
|
||||
return posts, err
|
||||
}
|
||||
|
||||
// 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 /
|
||||
// ViewerFollowRequested across a page of feed posts in three batched queries
|
||||
// (viewer→author accepted, author→viewer accepted, viewer→author pending).
|
||||
// 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
|
||||
}
|
||||
requested, err := r.followEdges(ctx,
|
||||
`SELECT following_id FROM follows
|
||||
WHERE follower_id = $1 AND status = 'pending' AND following_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]
|
||||
posts[i].ViewerFollowRequested = requested[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 +398,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 +665,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 +676,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 +687,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
|
||||
}
|
||||
+122
-3
@@ -1,6 +1,7 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -24,6 +25,11 @@ type R2 struct {
|
||||
client *s3.Client
|
||||
bucket string
|
||||
publicBase string // R2_PUBLIC_URL, no trailing slash
|
||||
|
||||
// privateBucket is a separate, non-public bucket for objects that must
|
||||
// only be reachable through a presigned GET (see PresignGet). Empty when
|
||||
// unconfigured, which makes PrivateConfigured report false.
|
||||
privateBucket string
|
||||
}
|
||||
|
||||
// R2Config captures the env vars needed to talk to R2.
|
||||
@@ -33,6 +39,9 @@ type R2Config struct {
|
||||
SecretAccessKey string
|
||||
Bucket string
|
||||
PublicURL string
|
||||
// PrivateBucket is optional: absent means private media is disabled,
|
||||
// while the public bucket keeps working.
|
||||
PrivateBucket string
|
||||
}
|
||||
|
||||
// Configured returns true if every required field is present.
|
||||
@@ -60,9 +69,10 @@ func NewR2(cfg R2Config) (*R2, error) {
|
||||
})
|
||||
|
||||
return &R2{
|
||||
client: client,
|
||||
bucket: cfg.Bucket,
|
||||
publicBase: trimTrailingSlash(cfg.PublicURL),
|
||||
client: client,
|
||||
bucket: cfg.Bucket,
|
||||
publicBase: trimTrailingSlash(cfg.PublicURL),
|
||||
privateBucket: cfg.PrivateBucket,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -92,6 +102,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 {
|
||||
@@ -107,3 +138,91 @@ func trimTrailingSlash(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private media
|
||||
//
|
||||
// Objects in the public bucket are world-readable by URL — fine for avatars
|
||||
// and post images, wrong for anything private (DM attachments). R2 public
|
||||
// access is per-bucket, not per-prefix, so private objects live in a
|
||||
// SEPARATE bucket that has no public access and no custom domain. The only
|
||||
// way to read them is a short-lived presigned GET, which the BFF issues after
|
||||
// checking that the caller is allowed to see that particular object.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PrivateConfigured reports whether the private bucket is available. Callers
|
||||
// use it to return 503 rather than silently falling back to public storage —
|
||||
// a fallback there would leak the very objects this bucket exists to protect.
|
||||
func (r *R2) PrivateConfigured() bool {
|
||||
return r != nil && r.privateBucket != ""
|
||||
}
|
||||
|
||||
// PresignPutPrivate returns a one-shot PUT URL for the PRIVATE bucket.
|
||||
//
|
||||
// Unlike PresignPut it deliberately returns no public URL: there isn't one,
|
||||
// and handing callers a URL-shaped string for a private object invites it
|
||||
// being persisted and treated as fetchable. Store the key instead, and mint a
|
||||
// read URL per request with PresignGet.
|
||||
func (r *R2) PresignPutPrivate(ctx context.Context, key, contentType string, expiry time.Duration) (uploadURL string, err error) {
|
||||
if !r.PrivateConfigured() {
|
||||
return "", errors.New("private media storage is not configured")
|
||||
}
|
||||
if key == "" {
|
||||
return "", errors.New("empty key")
|
||||
}
|
||||
|
||||
presigner := s3.NewPresignClient(r.client)
|
||||
req, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(r.privateBucket),
|
||||
Key: aws.String(key),
|
||||
ContentType: aws.String(contentType),
|
||||
}, s3.WithPresignExpires(expiry))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("presign private put: %w", err)
|
||||
}
|
||||
return req.URL, nil
|
||||
}
|
||||
|
||||
// PresignGet returns a short-lived read URL for an object in the private
|
||||
// bucket.
|
||||
//
|
||||
// Keep expiry short. The URL is a bearer credential for that one object:
|
||||
// anyone holding it can read until it expires, so it should be minted per
|
||||
// request and never persisted in a message payload or an offline cache.
|
||||
func (r *R2) PresignGet(ctx context.Context, key string, expiry time.Duration) (string, error) {
|
||||
if !r.PrivateConfigured() {
|
||||
return "", errors.New("private media storage is not configured")
|
||||
}
|
||||
if key == "" {
|
||||
return "", errors.New("empty key")
|
||||
}
|
||||
|
||||
presigner := s3.NewPresignClient(r.client)
|
||||
req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(r.privateBucket),
|
||||
Key: aws.String(key),
|
||||
}, s3.WithPresignExpires(expiry))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("presign get: %w", err)
|
||||
}
|
||||
return req.URL, nil
|
||||
}
|
||||
|
||||
// DeletePrivate removes an object from the private bucket. Retention and
|
||||
// account deletion need real erasure, not a tombstone row that leaves the
|
||||
// bytes sitting in storage.
|
||||
func (r *R2) DeletePrivate(ctx context.Context, key string) error {
|
||||
if !r.PrivateConfigured() {
|
||||
return errors.New("private media storage is not configured")
|
||||
}
|
||||
if key == "" {
|
||||
return errors.New("empty key")
|
||||
}
|
||||
if _, err := r.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(r.privateBucket),
|
||||
Key: aws.String(key),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("delete private object %q: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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,541 @@
|
||||
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", "accepted", userID, q, limit, offset)
|
||||
}
|
||||
|
||||
// ListIncomingRequests returns the users with a pending request to follow
|
||||
// userID (newest first), optionally filtered by `q`, plus the total count.
|
||||
func (r *FollowRepo) ListIncomingRequests(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||
return r.listRelations(ctx, "f.following_id = $1", "f.follower_id", "pending", 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", "accepted", userID, q, limit, offset)
|
||||
}
|
||||
|
||||
// listRelations powers follower/following/pending-request listings: `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), `status` is the edge
|
||||
// status to match ('accepted' or 'pending'). `q` filters by name/handle
|
||||
// (ILIKE); empty `q` disables the filter. The column/status expressions are
|
||||
// fixed internal constants — never user input — so this stays injection-safe.
|
||||
func (r *FollowRepo) listRelations(
|
||||
ctx context.Context, whereOwn, joinCol, status, 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 = '` + status + `'
|
||||
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 = '` + status + `'
|
||||
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
|
||||
}
|
||||
|
||||
// BroadcastToAllUsers writes one notification of `kind` to every active user
|
||||
// except `actorID`, in a single INSERT…SELECT. One round trip regardless of
|
||||
// audience size — the alternative (a row per user from Go) would be hundreds
|
||||
// of round trips on the request path.
|
||||
//
|
||||
// Deliberately does NOT filter on notification_prefs: ListByUser already
|
||||
// excludes kinds the reader has disabled for the in_app channel at read time,
|
||||
// so writing the rows unconditionally keeps a re-enabled preference able to
|
||||
// surface history — exactly how the existing kinds behave.
|
||||
//
|
||||
// Banned accounts are skipped; they can't sign in to read it anyway.
|
||||
func (r *NotificationRepo) BroadcastToAllUsers(ctx context.Context, kind, actorID string) (int64, error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
INSERT INTO notifications (user_id, actor_id, kind)
|
||||
SELECT u.id, $2, $1
|
||||
FROM users u
|
||||
WHERE u.id <> $2 AND u.banned_at IS NULL`, kind, actorID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("broadcast notification: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// PushRecipientsForKind lists the ids of users who should receive a *push* for
|
||||
// `kind` — everyone except the actor, minus banned accounts, minus anyone who
|
||||
// turned this kind off for the push channel, and minus anyone with no push
|
||||
// subscription registered at all.
|
||||
//
|
||||
// That last filter is what keeps a broadcast cheap: only devotees who actually
|
||||
// enabled web push are worth iterating, which is typically a small fraction of
|
||||
// the user base.
|
||||
func (r *NotificationRepo) PushRecipientsForKind(ctx context.Context, kind, actorID string) ([]string, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT DISTINCT u.id
|
||||
FROM users u
|
||||
JOIN push_subscriptions ps ON ps.user_id = u.id
|
||||
WHERE u.id <> $2 AND u.banned_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM notification_prefs np
|
||||
WHERE np.user_id = u.id AND np.kind = $1
|
||||
AND np.channel = 'push' AND np.enabled = FALSE)`, kind, actorID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list push recipients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("scan push recipient: %w", err)
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// 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 `json:"users"`
|
||||
UsersLast7Days int `json:"users_last_7_days"`
|
||||
Posts int `json:"posts"`
|
||||
PostsLast7Days int `json:"posts_last_7_days"`
|
||||
Collections int `json:"collections"`
|
||||
PublicCollections int `json:"public_collections"`
|
||||
Bookmarks int `json:"bookmarks"`
|
||||
Reshares int `json:"reshares"`
|
||||
Feedback int `json:"feedback"`
|
||||
FeedbackLast7Days int `json:"feedback_last_7_days"`
|
||||
FeedbackByCategory []CategoryCount `json:"feedback_by_category"`
|
||||
}
|
||||
|
||||
type CategoryCount struct {
|
||||
Category string `json:"category"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -180,6 +180,80 @@ func (r *TranscriptionRepo) GetPlaces(ctx context.Context) ([]bson.M, error) {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// TranscriptionBooks is the canonical set of scriptures that appear in the
|
||||
// free-text `reference` field of transcriptions (Prabhupāda's lectures are
|
||||
// commentaries on specific verses). Browsing by "book" is derived from the
|
||||
// reference string — there are no structured book/chapter columns. Ordered for
|
||||
// display fallbacks; the listing endpoints filter out books with no transcripts.
|
||||
var TranscriptionBooks = []struct{ Code, Title string }{
|
||||
{"SB", "Śrīmad-Bhāgavatam"},
|
||||
{"BG", "Bhagavad-gītā"},
|
||||
{"CC", "Caitanya-caritāmṛta"},
|
||||
{"BS", "Brahma-saṁhitā"},
|
||||
{"NOD", "The Nectar of Devotion"},
|
||||
{"ISO", "Śrī Īśopaniṣad"},
|
||||
}
|
||||
|
||||
// bookTitle returns the display title for a known book code, or "" if unknown.
|
||||
func bookTitle(code string) string {
|
||||
for _, b := range TranscriptionBooks {
|
||||
if b.Code == code {
|
||||
return b.Title
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// bookRefFilter matches published transcriptions whose reference begins with the
|
||||
// given book code. The trailing "([^A-Za-z]|$)" is a boundary so e.g. "BS" can't
|
||||
// match "BG"; no code is a prefix of another, so this is unambiguous.
|
||||
func bookRefFilter(code string) bson.M {
|
||||
return bson.M{
|
||||
"isPublished": true,
|
||||
"reference": bson.M{"$regex": "^" + code + "([^A-Za-z]|$)"},
|
||||
}
|
||||
}
|
||||
|
||||
// GetBooks returns the books that have at least one transcript, as
|
||||
// {_id, title, count} — mirroring the {_id, count} shape the frontend already
|
||||
// consumes for years/places. One count query per book (like GetTypeDistribution).
|
||||
func (r *TranscriptionRepo) GetBooks(ctx context.Context) ([]bson.M, error) {
|
||||
results := make([]bson.M, 0, len(TranscriptionBooks))
|
||||
for _, b := range TranscriptionBooks {
|
||||
count, err := r.col.CountDocuments(ctx, bookRefFilter(b.Code))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
results = append(results, bson.M{"_id": b.Code, "title": b.Title, "count": count})
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetByBook lists the transcript summaries for a book code, sorted
|
||||
// chronologically (same projection/sort as GetByType).
|
||||
func (r *TranscriptionRepo) GetByBook(ctx context.Context, code string) ([]TranscriptionSummaryDoc, error) {
|
||||
opts := options.Find().
|
||||
SetSort(bson.D{{Key: "year", Value: 1}, {Key: "month", Value: 1}, {Key: "day", Value: 1}}).
|
||||
SetProjection(transcriptionSummaryProjection)
|
||||
cursor, err := r.col.Find(ctx, bookRefFilter(code), opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
var results []TranscriptionSummaryDoc
|
||||
if err := cursor.All(ctx, &results); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// BookTitle exposes the registry lookup to the handler for validation.
|
||||
func (r *TranscriptionRepo) BookTitle(code string) string {
|
||||
return bookTitle(code)
|
||||
}
|
||||
|
||||
func (r *TranscriptionRepo) GetTypeDistribution(ctx context.Context) (int64, int64, error) {
|
||||
lectureCount, err := r.col.CountDocuments(ctx, bson.M{"type": "lecture", "isPublished": true})
|
||||
if err != nil {
|
||||
|
||||
@@ -29,21 +29,67 @@ 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,
|
||||
bookmark_limit, public_collection_limit, private_collection_limit,
|
||||
disciple_id, bio`
|
||||
|
||||
// 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,
|
||||
&u.BookmarkLimit, &u.PublicCollectionLimit, &u.PrivateCollectionLimit,
|
||||
&u.DiscipleID, &u.Bio,
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
Bio *string // nil=leave, ""=clear, text=set
|
||||
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 +108,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 +153,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 +166,108 @@ 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("bio", p.Bio)
|
||||
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 +280,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 +318,205 @@ 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()
|
||||
}
|
||||
|
||||
// DiscipleLink pairs a Mongo disciple id with the platform account an admin
|
||||
// has linked to it. Powers the /disciples browser highlight: the reader page
|
||||
// fetches the whole (small) set once and marks the matching cards.
|
||||
type DiscipleLink struct {
|
||||
DiscipleID string `json:"disciple_id"`
|
||||
Handle string `json:"handle"`
|
||||
Name string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
// ListDiscipleLinks returns every user→disciple link. Deliberately unpaged:
|
||||
// only Śrīla Prabhupāda's own disciples are ever linked, so this is dozens of
|
||||
// rows at most and the partial index on disciple_id keeps it a cheap scan.
|
||||
// Suspended accounts are excluded so a banned user can't keep a public badge.
|
||||
func (r *UserRepo) ListDiscipleLinks(ctx context.Context) ([]DiscipleLink, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT disciple_id, handle, name, avatar_url
|
||||
FROM users
|
||||
WHERE disciple_id IS NOT NULL AND banned_at IS NULL
|
||||
ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list disciple links: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []DiscipleLink{}
|
||||
for rows.Next() {
|
||||
var l DiscipleLink
|
||||
if err := rows.Scan(&l.DiscipleID, &l.Handle, &l.Name, &l.AvatarURL); err != nil {
|
||||
return nil, fmt.Errorf("scan disciple link: %w", err)
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, 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 +588,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 {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres
|
||||
// (vani_of_the_day) while the candidate content is sampled from Mongo (the
|
||||
// verses / prose_chapters / transcriptions collections).
|
||||
type VaniRepo struct {
|
||||
pg *Postgres
|
||||
mongo *MongoDB
|
||||
}
|
||||
|
||||
func NewVaniRepo(pg *Postgres, m *MongoDB) *VaniRepo {
|
||||
return &VaniRepo{pg: pg, mongo: m}
|
||||
}
|
||||
|
||||
// --- Postgres: the per-day record ---
|
||||
|
||||
// GetForDay returns the pick stored for the given UTC day, or (nil, nil) when
|
||||
// nothing has been chosen yet.
|
||||
func (r *VaniRepo) GetForDay(ctx context.Context, day time.Time) (*model.VaniPick, error) {
|
||||
var kind, refID string
|
||||
var payload []byte
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT kind, ref_id, payload FROM vani_of_the_day WHERE day = $1`, day,
|
||||
).Scan(&kind, &refID, &payload)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get vani for day: %w", err)
|
||||
}
|
||||
var p model.VaniPayload
|
||||
if err := json.Unmarshal(payload, &p); err != nil {
|
||||
return nil, fmt.Errorf("decode vani payload: %w", err)
|
||||
}
|
||||
return &model.VaniPick{Day: day, Kind: kind, RefID: refID, Payload: p}, nil
|
||||
}
|
||||
|
||||
// RecentRefIDs returns the ref_ids chosen on or after `since` — the 90-day
|
||||
// exclusion set for the picker.
|
||||
func (r *VaniRepo) RecentRefIDs(ctx context.Context, since time.Time) ([]string, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx,
|
||||
`SELECT ref_id FROM vani_of_the_day WHERE day >= $1`, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recent vani ref ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Insert stores the day's pick, ignoring a race where another request already
|
||||
// wrote today's (ON CONFLICT (day) DO NOTHING). Callers re-read GetForDay to
|
||||
// get the authoritative row.
|
||||
func (r *VaniRepo) Insert(ctx context.Context, pick *model.VaniPick) error {
|
||||
b, err := json.Marshal(pick.Payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.pg.Pool.Exec(ctx,
|
||||
`INSERT INTO vani_of_the_day (day, kind, ref_id, payload)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
ON CONFLICT (day) DO NOTHING`,
|
||||
pick.Day, pick.Kind, pick.RefID, b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert vani pick: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BookTitle resolves a book code to its display title (from book_meta.data.title),
|
||||
// or "" if unknown. Used to label a prose pick with its book name. Decoding into
|
||||
// a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode
|
||||
// as bson.D, so a `raw["data"].(bson.M)` assertion would silently fail).
|
||||
func (r *VaniRepo) BookTitle(ctx context.Context, book string) string {
|
||||
var doc struct {
|
||||
Data struct {
|
||||
Title string `bson:"title"`
|
||||
} `bson:"data"`
|
||||
}
|
||||
if err := r.mongo.Collection("book_meta").
|
||||
FindOne(ctx, bson.D{{Key: "book", Value: book}}).Decode(&doc); err != nil {
|
||||
return ""
|
||||
}
|
||||
return doc.Data.Title
|
||||
}
|
||||
|
||||
// --- Mongo: random candidate sampling ---
|
||||
|
||||
// excludeIDs turns hex _id strings into a $nin ObjectID array.
|
||||
func excludeIDs(hexes []string) bson.A {
|
||||
arr := bson.A{}
|
||||
for _, h := range hexes {
|
||||
if oid, err := bson.ObjectIDFromHex(h); err == nil {
|
||||
arr = append(arr, oid)
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
func (r *VaniRepo) sampleOne(ctx context.Context, coll string, match bson.D, out interface{}) (bool, error) {
|
||||
cur, err := r.mongo.Collection(coll).Aggregate(ctx, mongo.Pipeline{
|
||||
bson.D{{Key: "$match", Value: match}},
|
||||
bson.D{{Key: "$sample", Value: bson.D{{Key: "size", Value: 1}}}},
|
||||
})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("sample %s: %w", coll, err)
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
if cur.Next(ctx) {
|
||||
if err := cur.Decode(out); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
return false, cur.Err()
|
||||
}
|
||||
|
||||
// SampleVerse returns a random verse with a translation, excluding the given
|
||||
// _id hexes. Returns nil when nothing matches.
|
||||
func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) {
|
||||
match := bson.D{
|
||||
{Key: "_id", Value: bson.D{{Key: "$nin", Value: excludeIDs(excludeHex)}}},
|
||||
{Key: "translation", Value: bson.D{{Key: "$exists", Value: true}, {Key: "$ne", Value: ""}}},
|
||||
}
|
||||
var d VerseDoc
|
||||
ok, err := r.sampleOne(ctx, "verses", match, &d)
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// SampleProse returns a random prose chapter with body text, excluding the
|
||||
// given _id hexes.
|
||||
func (r *VaniRepo) SampleProse(ctx context.Context, excludeHex []string) (*ProseChapterDoc, error) {
|
||||
match := bson.D{
|
||||
{Key: "_id", Value: bson.D{{Key: "$nin", Value: excludeIDs(excludeHex)}}},
|
||||
{Key: "paragraphs.0", Value: bson.D{{Key: "$exists", Value: true}}},
|
||||
}
|
||||
var d ProseChapterDoc
|
||||
ok, err := r.sampleOne(ctx, "prose_chapters", match, &d)
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// SampleTranscript returns a random published transcript with content,
|
||||
// excluding the given _id hexes.
|
||||
func (r *VaniRepo) SampleTranscript(ctx context.Context, excludeHex []string) (*TranscriptionDoc, error) {
|
||||
match := bson.D{
|
||||
{Key: "_id", Value: bson.D{{Key: "$nin", Value: excludeIDs(excludeHex)}}},
|
||||
{Key: "isPublished", Value: true},
|
||||
{Key: "content.0", Value: bson.D{{Key: "$exists", Value: true}}},
|
||||
}
|
||||
var d TranscriptionDoc
|
||||
ok, err := r.sampleOne(ctx, "transcriptions", match, &d)
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
+548
-23
@@ -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"
|
||||
)
|
||||
@@ -29,8 +34,14 @@ type Server struct {
|
||||
pg *repository.Postgres
|
||||
redis *repository.Redis
|
||||
|
||||
// discipleRepo is held on the struct (unlike the other Mongo repos, which
|
||||
// stay local to New) because initAuth re-builds discipleH with the
|
||||
// Postgres userRepo once it exists — see the direct-disciple link feature.
|
||||
discipleRepo *repository.DiscipleRepo
|
||||
|
||||
// Worker lifecycle
|
||||
workerCancel context.CancelFunc
|
||||
workerCtx context.Context // derived in initShortener; cancelled on Close
|
||||
|
||||
// Handlers
|
||||
bookH *handler.BookHandler
|
||||
@@ -43,13 +54,28 @@ type Server struct {
|
||||
otherDocH *handler.OtherDocumentHandler
|
||||
transcriptionH *handler.TranscriptionHandler
|
||||
searchH *handler.SearchHandler
|
||||
vaniH *handler.VaniHandler
|
||||
sitemapH *handler.SitemapHandler
|
||||
shortURLH *handler.ShortURLHandler
|
||||
authH *handler.AuthHandler
|
||||
oauthH *handler.OAuthHandler
|
||||
userH *handler.UserHandler
|
||||
audioH *handler.AudioProgressHandler
|
||||
postH *handler.PostHandler
|
||||
commentH *handler.CommentHandler
|
||||
bookmarkH *handler.BookmarkHandler
|
||||
collectionH *handler.CollectionHandler
|
||||
profileH *handler.ProfileHandler
|
||||
relationH *handler.RelationshipHandler
|
||||
soketiWebhookH *handler.SoketiWebhookHandler
|
||||
broadcastAuthH *handler.BroadcastingAuthHandler
|
||||
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,23 +102,28 @@ 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,
|
||||
logger: logger,
|
||||
mongo: mongoDB,
|
||||
|
||||
discipleRepo: discipleRepo,
|
||||
|
||||
// Initialize handlers
|
||||
bookH: handler.NewBookHandler(bookRepo),
|
||||
letterH: handler.NewLetterHandler(letterRepo),
|
||||
essayH: handler.NewEssayHandler(essayRepo),
|
||||
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
|
||||
discipleH: handler.NewDiscipleHandler(discipleRepo),
|
||||
// userRepo half is attached later in initAuth (Postgres isn't up yet).
|
||||
discipleH: handler.NewDiscipleHandler(discipleRepo, nil),
|
||||
journalH: handler.NewJournalHandler(journalRepo),
|
||||
magazineH: handler.NewMagazineHandler(magazineRepo),
|
||||
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 +150,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,
|
||||
@@ -126,6 +158,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
||||
SecretAccessKey: cfg.R2SecretAccessKey,
|
||||
Bucket: cfg.R2Bucket,
|
||||
PublicURL: cfg.R2PublicURL,
|
||||
PrivateBucket: cfg.R2PrivateBucket,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("auth: R2 unavailable — avatar uploads will return 503", zap.Error(err))
|
||||
@@ -143,14 +176,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 +205,130 @@ 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)
|
||||
|
||||
// Re-wire the disciple handler now that Postgres-backed users exist: its
|
||||
// /disciples/linked route joins the Mongo disciple records to the platform
|
||||
// accounts an admin has linked to them. Built in New() with a nil userRepo
|
||||
// (Mongo is up before Postgres), so this is where it gets the other half.
|
||||
if s.discipleRepo != nil {
|
||||
s.discipleH = handler.NewDiscipleHandler(s.discipleRepo, userRepo)
|
||||
}
|
||||
|
||||
// Audio playback resume points — per-user, per-file last position that
|
||||
// lets the floating player pick up where it left off across devices.
|
||||
audioProgressRepo := repository.NewAudioProgressRepo(s.pg)
|
||||
s.audioH = handler.NewAudioProgressHandler(service.NewAudioProgressService(audioProgressRepo))
|
||||
|
||||
// Vāṇī of the Day — one daily pick (prose/verse/transcript) for the home
|
||||
// page, chosen from Mongo content and recorded in Postgres so it doesn't
|
||||
// repeat within 90 days.
|
||||
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
|
||||
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
|
||||
|
||||
// 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,
|
||||
)
|
||||
// Channel authorization for private-* subscriptions. Rules are explicit
|
||||
// and default-deny; chat conversation channels get added here.
|
||||
s.broadcastAuthH = handler.NewBroadcastingAuthHandler(rtClient, logger,
|
||||
handler.NotificationsRule(),
|
||||
)
|
||||
|
||||
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, userRepo, 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)
|
||||
|
||||
// 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, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL)
|
||||
s.adminH = handler.NewAdminHandler(adminSvc)
|
||||
}
|
||||
|
||||
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
||||
@@ -172,6 +337,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 +394,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.
|
||||
@@ -255,14 +444,25 @@ func (s *Server) setupRouter() {
|
||||
|
||||
// Global middleware
|
||||
r.Use(chiMiddleware.RequestID)
|
||||
r.Use(chiMiddleware.RealIP)
|
||||
// Our RealIP (not chi's): a single trusted edge proxy (Dokploy's Traefik)
|
||||
// appends the real client to the right of X-Forwarded-For, so we read the
|
||||
// right-most entry. chi's RealIP trusts the spoofable left-most entry.
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger(s.logger))
|
||||
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 +476,51 @@ 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 {
|
||||
// Read-only public snapshot. The feedback acknowledge/resolve actions
|
||||
// moved to the admin panel (POST /api/v1/admin/feedback/{id}/...) — they
|
||||
// must not be exposed on this unauthenticated page.
|
||||
r.Get("/stats", s.statsH.Render)
|
||||
}
|
||||
|
||||
// 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 +528,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 +541,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))
|
||||
@@ -410,6 +634,9 @@ func (s *Server) setupRouter() {
|
||||
r.Get("/letters", s.discipleH.LetterCounts)
|
||||
r.Get("/suffixes", s.discipleH.SuffixCounts)
|
||||
r.Get("/search", s.discipleH.Search)
|
||||
// Disciples with a platform account (admin-linked) — drives the
|
||||
// highlight in the /disciples browser.
|
||||
r.Get("/linked", s.discipleH.Linked)
|
||||
})
|
||||
|
||||
// Journals
|
||||
@@ -442,9 +669,11 @@ func (s *Server) setupRouter() {
|
||||
r.Get("/years", s.transcriptionH.GetYears)
|
||||
r.Get("/places", s.transcriptionH.GetPlaces)
|
||||
r.Get("/types", s.transcriptionH.GetTypeDistribution)
|
||||
r.Get("/books", s.transcriptionH.GetBooks)
|
||||
r.Get("/year/{year}", s.transcriptionH.GetByYear)
|
||||
r.Get("/place/{place}", s.transcriptionH.GetByPlace)
|
||||
r.Get("/type/{type}", s.transcriptionH.GetByType)
|
||||
r.Get("/book/{book}", s.transcriptionH.GetByBook)
|
||||
r.Get("/adjacent/{slug}", s.transcriptionH.GetAdjacent)
|
||||
r.Get("/{slug}", s.transcriptionH.GetBySlug)
|
||||
})
|
||||
@@ -452,6 +681,9 @@ func (s *Server) setupRouter() {
|
||||
// Search (Typesense)
|
||||
r.Get("/search", s.searchH.Search)
|
||||
|
||||
// Vāṇī of the Day — public daily content pick for the home page.
|
||||
r.Get("/vani/today", s.vaniH.Today)
|
||||
|
||||
// --- Auth ---
|
||||
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
|
||||
// and email verification. The /auth/me + /auth/resend-verification routes
|
||||
@@ -463,28 +695,67 @@ 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)
|
||||
// Audio player resume points roam with the account too, so a
|
||||
// lecture picks up where you left off on any device. Schema is
|
||||
// owned by the frontend; the server stores opaque key/position
|
||||
// rows and merges newest-wins.
|
||||
r.Get("/me/audio-progress", s.audioH.Get)
|
||||
r.Put("/me/audio-progress", s.audioH.Put)
|
||||
r.Delete("/me/audio-progress", s.audioH.Delete)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -494,16 +765,80 @@ func (s *Server) setupRouter() {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
// Public — read individual posts without auth.
|
||||
r.Get("/{id}", s.postH.Get)
|
||||
// Protected — create / delete / image presign / "my posts".
|
||||
// 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)
|
||||
// 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 +857,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.Get("/{id}", s.collectionH.Get)
|
||||
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 +884,87 @@ 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)
|
||||
}
|
||||
|
||||
// Pusher channel authorization — pusher-js posts here (as its
|
||||
// `authEndpoint`) before subscribing to a private-/presence- channel.
|
||||
// Session-authenticated: the signature is only minted for a caller
|
||||
// who has proven who they are and passed the channel's rule.
|
||||
if s.broadcastAuthH != nil {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Post("/broadcasting/auth", s.broadcastAuthH.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)
|
||||
})
|
||||
})
|
||||
|
||||
// Caller's pending incoming follow requests (the notifications
|
||||
// "Friend Requests" tab). Caller-scoped, so it lives outside /u/{handle}.
|
||||
if s.profileH != nil {
|
||||
r.With(middleware.RequireAuth(s.authIssuer, s.userRepo)).
|
||||
Get("/follow-requests", s.profileH.IncomingFollowRequests)
|
||||
}
|
||||
|
||||
r.Route("/u", func(r chi.Router) {
|
||||
if s.profileH == nil {
|
||||
r.HandleFunc("/*", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
r.Get("/{handle}", s.profileH.Get)
|
||||
r.Get("/{handle}/feed", s.profileH.Feed)
|
||||
// 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 +983,119 @@ 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)
|
||||
r.HandleFunc("/feedback/mine", s.authUnavailable)
|
||||
return
|
||||
}
|
||||
// Submit — OptionalAuth (anonymous callers allowed).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||
r.Post("/feedback", s.feedbackH.Submit)
|
||||
})
|
||||
// My submissions — RequireAuth. Lists the caller's own rows
|
||||
// with status + admin reply for the /settings/feedbacks page.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||
r.Get("/feedback/mine", s.feedbackH.Mine)
|
||||
})
|
||||
})
|
||||
|
||||
// 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("/stats", s.adminH.Stats)
|
||||
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.Post("/users/{id}/limits", s.adminH.SetUserLimits)
|
||||
// Direct-disciple linking: mark an account as one of Śrīla
|
||||
// Prabhupāda's initiated disciples (or clear the mark).
|
||||
r.Get("/disciples", s.adminH.ListDisciples)
|
||||
r.Post("/users/{id}/disciple", s.adminH.LinkDisciple)
|
||||
r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
|
||||
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.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
|
||||
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
|
||||
r.Get("/reports", s.adminH.ListReports)
|
||||
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
||||
r.Get("/short-urls", s.adminH.ListShortURLs)
|
||||
r.Get("/short-urls/stats", s.adminH.ShortURLStats)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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
|
||||
stats *repository.StatsRepo
|
||||
notif *NotificationService
|
||||
feedback *FeedbackService
|
||||
// users / disciples back the direct-disciple linking feature: the link
|
||||
// column lives in Postgres (users) while the disciple record lives in
|
||||
// Mongo (disciples), so the service is where the two stores meet. Both
|
||||
// may be nil in degraded boots (no Mongo) — the link methods guard.
|
||||
users *repository.UserRepo
|
||||
disciples *repository.DiscipleRepo
|
||||
mail mailer.Mailer
|
||||
logger *zap.Logger
|
||||
frontendURL string
|
||||
}
|
||||
|
||||
func NewAdminService(repo *repository.AdminRepo, stats *repository.StatsRepo, notif *NotificationService, feedback *FeedbackService, users *repository.UserRepo, disciples *repository.DiscipleRepo, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService {
|
||||
return &AdminService{repo: repo, stats: stats, notif: notif, feedback: feedback, users: users, disciples: disciples, mail: mail, logger: logger, frontendURL: frontendURL}
|
||||
}
|
||||
|
||||
// Stats returns the system snapshot (counts + 7-day deltas + feedback
|
||||
// breakdown) — the same data the public /stats page renders, as JSON for the
|
||||
// admin dashboard home.
|
||||
func (s *AdminService) Stats(ctx context.Context) (*repository.StatsSnapshot, error) {
|
||||
return s.stats.Snapshot(ctx)
|
||||
}
|
||||
|
||||
// AcknowledgeFeedback marks a feedback row acknowledged and emails the author
|
||||
// that we've seen it. Reuses FeedbackService so the email is identical to the
|
||||
// (now removed) public /stats action. Returns ErrAlreadyAcknowledged when the
|
||||
// row was already acknowledged.
|
||||
func (s *AdminService) AcknowledgeFeedback(ctx context.Context, id string) error {
|
||||
return s.feedback.Acknowledge(ctx, id)
|
||||
}
|
||||
|
||||
// ResolveFeedback marks a feedback row resolved and emails the author a
|
||||
// thank-you. Returns ErrAlreadyResolved when the row was already resolved.
|
||||
func (s *AdminService) ResolveFeedback(ctx context.Context, id string) error {
|
||||
return s.feedback.Resolve(ctx, id)
|
||||
}
|
||||
|
||||
func (s *AdminService) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
||||
users, total, err := s.repo.ListUsers(ctx, q, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
s.hydrateDiscipleNames(ctx, users)
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
// hydrateDiscipleNames fills DiscipleName on any linked rows in the page with a
|
||||
// single Mongo lookup. Best-effort by design: the panel is far more useful with
|
||||
// an un-named link than with a 500, so a Mongo failure logs and leaves the
|
||||
// names nil (the UI then shows the bare "linked" state).
|
||||
func (s *AdminService) hydrateDiscipleNames(ctx context.Context, users []model.AdminUser) {
|
||||
if s.disciples == nil {
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.DiscipleID != nil && *u.DiscipleID != "" {
|
||||
ids = append(ids, *u.DiscipleID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
docs, err := s.disciples.FindByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
s.logger.Warn("admin: hydrate disciple names failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
byID := make(map[string]string, len(docs))
|
||||
for _, d := range docs {
|
||||
byID[d.ID.Hex()] = strings.TrimSpace(d.InitiatedName + " " + d.Suffix)
|
||||
}
|
||||
for i := range users {
|
||||
if users[i].DiscipleID == nil {
|
||||
continue
|
||||
}
|
||||
if name, ok := byID[*users[i].DiscipleID]; ok {
|
||||
users[i].DiscipleName = &name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ErrDiscipleNotFound is returned by LinkDisciple when the requested disciple
|
||||
// id doesn't resolve to a document in Mongo.
|
||||
var ErrDiscipleNotFound = errors.New("disciple not found")
|
||||
|
||||
// ErrDisciplesUnavailable is returned when the Mongo-backed disciple
|
||||
// collection isn't wired (degraded boot) — linking can't be validated, so we
|
||||
// refuse rather than write an unverified id.
|
||||
var ErrDisciplesUnavailable = errors.New("disciple directory unavailable")
|
||||
|
||||
// SearchDisciples backs the admin panel's disciple picker. An empty query
|
||||
// returns the first page alphabetically so the picker opens with content.
|
||||
// Each option carries the handle of the account already linked to it, letting
|
||||
// the UI disable those rows instead of surfacing a conflict on submit.
|
||||
func (s *AdminService) SearchDisciples(ctx context.Context, q string, limit int) ([]model.AdminDisciple, error) {
|
||||
if s.disciples == nil {
|
||||
return nil, ErrDisciplesUnavailable
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 25
|
||||
}
|
||||
docs, err := s.disciples.Search(ctx, strings.TrimSpace(q), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Which disciples are already claimed, and by whom.
|
||||
claimed := map[string]string{}
|
||||
if s.users != nil {
|
||||
links, err := s.users.ListDiscipleLinks(ctx)
|
||||
if err != nil {
|
||||
s.logger.Warn("admin: load disciple links failed", zap.Error(err))
|
||||
} else {
|
||||
for _, l := range links {
|
||||
claimed[l.DiscipleID] = l.Handle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]model.AdminDisciple, 0, len(docs))
|
||||
for _, d := range docs {
|
||||
id := d.ID.Hex()
|
||||
opt := model.AdminDisciple{
|
||||
ID: id,
|
||||
InitiatedName: d.InitiatedName,
|
||||
Suffix: d.Suffix,
|
||||
InitiationDate: d.InitiationDate,
|
||||
InitiationPlace: d.InitiationPlace,
|
||||
}
|
||||
if h, ok := claimed[id]; ok {
|
||||
handle := h
|
||||
opt.LinkedHandle = &handle
|
||||
}
|
||||
out = append(out, opt)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LinkDisciple marks a user as one of Śrīla Prabhupāda's direct disciples by
|
||||
// pointing them at a disciple record. The id is validated against Mongo first
|
||||
// so a typo can't create a link that renders as an unnamed badge forever.
|
||||
// Returns ErrAdminUserNotFound, ErrDiscipleNotFound, or
|
||||
// repository.ErrDiscipleClaimed.
|
||||
func (s *AdminService) LinkDisciple(ctx context.Context, userID, discipleID string) error {
|
||||
if s.disciples == nil {
|
||||
return ErrDisciplesUnavailable
|
||||
}
|
||||
doc, err := s.disciples.FindByID(ctx, strings.TrimSpace(discipleID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if doc == nil {
|
||||
return ErrDiscipleNotFound
|
||||
}
|
||||
found, previous, err := s.repo.LinkDisciple(ctx, userID, doc.ID.Hex())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrAdminUserNotFound
|
||||
}
|
||||
|
||||
// Everything below is first-link-only. Correcting a mis-link (previous
|
||||
// != nil) must not greet the devotee twice or re-announce them.
|
||||
if previous != nil && *previous != "" {
|
||||
return nil
|
||||
}
|
||||
if s.users == nil {
|
||||
return nil
|
||||
}
|
||||
u, err := s.users.FindByID(ctx, userID)
|
||||
if err != nil || u == nil {
|
||||
s.logger.Warn("disciple linked but user lookup failed; skipping welcome + announcement",
|
||||
zap.String("user_id", userID), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
s.sendDiscipleWelcome(u, strings.TrimSpace(doc.InitiatedName+" "+doc.Suffix))
|
||||
s.announceDiscipleJoined(ctx, u, doc.ID.Hex())
|
||||
return nil
|
||||
}
|
||||
|
||||
// announceDiscipleJoined tells the rest of the community that a direct
|
||||
// disciple has joined — in-app for everyone, plus web push for those who've
|
||||
// enabled it. No email: see the deliverability reasoning behind keeping
|
||||
// announcements off the transactional mail domain.
|
||||
//
|
||||
// The DB claim is atomic and synchronous (so a double-click can't double
|
||||
// announce), but the fan-out itself runs detached: pushing to hundreds of
|
||||
// devotees is one external HTTP call each, far too slow to hold the admin's
|
||||
// request open. `context.WithoutCancel` keeps it alive past the response, with
|
||||
// a hard ceiling so a wedged run can't leak forever.
|
||||
func (s *AdminService) announceDiscipleJoined(ctx context.Context, u *model.User, discipleID string) {
|
||||
if s.notif == nil {
|
||||
return
|
||||
}
|
||||
if u.BannedAt != nil {
|
||||
// Don't announce a suspended account to the community.
|
||||
s.logger.Info("disciple linked on a suspended account; skipping announcement",
|
||||
zap.String("user_id", u.ID))
|
||||
return
|
||||
}
|
||||
claimed, err := s.repo.ClaimDiscipleAnnouncement(ctx, u.ID, discipleID)
|
||||
if err != nil {
|
||||
s.logger.Warn("disciple announcement claim failed; skipping broadcast",
|
||||
zap.String("user_id", u.ID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if !claimed {
|
||||
// Already announced previously — nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
bg, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Minute)
|
||||
go func() {
|
||||
defer cancel()
|
||||
if err := s.notif.BroadcastDiscipleJoined(bg, u); err != nil {
|
||||
s.logger.Error("disciple-joined broadcast failed",
|
||||
zap.String("user_id", u.ID), zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// sendDiscipleWelcome emails the newly-recognised disciple. Fire-and-forget,
|
||||
// exactly like the feedback emails: the link is already committed, so a mail
|
||||
// failure must not fail the admin's action — it logs and moves on.
|
||||
func (s *AdminService) sendDiscipleWelcome(u *model.User, discipleName string) {
|
||||
if u.Email == nil || strings.TrimSpace(*u.Email) == "" {
|
||||
// OAuth-only account with no address on file — nothing to send.
|
||||
s.logger.Info("disciple linked; no email on file, skipping welcome",
|
||||
zap.String("user_id", u.ID))
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := discipleWelcomeEmailTpl.Execute(&buf, map[string]any{
|
||||
"RecipientName": strings.TrimSpace(u.Name),
|
||||
"DiscipleName": discipleName,
|
||||
"ProfileURL": s.frontendURL + "/u/" + u.Handle,
|
||||
}); err != nil {
|
||||
s.logger.Warn("render disciple welcome email failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := s.mail.Send(strings.TrimSpace(*u.Email),
|
||||
"Welcome, and thank you — Prabhupāda Says", buf.String()); err != nil {
|
||||
s.logger.Warn("disciple welcome email send failed",
|
||||
zap.String("user_id", u.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// UnlinkDisciple clears a user's direct-disciple link. Idempotent — clearing an
|
||||
// already-clear link succeeds.
|
||||
func (s *AdminService) UnlinkDisciple(ctx context.Context, userID string) error {
|
||||
ok, err := s.repo.UnlinkDisciple(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrAdminUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 and emails it to the submitter when an
|
||||
// address is known. It does not resolve the row (resolve is a separate
|
||||
// action). 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, note string) (bool, error) {
|
||||
return s.repo.ResolveReport(ctx, reportType, id, note)
|
||||
}
|
||||
|
||||
// ─── 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)
|
||||
}
|
||||
|
||||
// SetUserLimits writes per-user save-limit overrides (a nil field resets that
|
||||
// limit to the plan default). found=false when the id is unknown.
|
||||
func (s *AdminService) SetUserLimits(ctx context.Context, userID string, bookmark, publicCollection, privateCollection *int) (bool, error) {
|
||||
return s.repo.SetUserLimits(ctx, userID, bookmark, publicCollection, privateCollection)
|
||||
}
|
||||
|
||||
// feedbackReplyEmailTpl renders the reply email. Reply text is auto-escaped by
|
||||
// html/template, so admin-entered text can't inject markup.
|
||||
// discipleWelcomeEmailTpl greets a devotee an admin has just recognised as one
|
||||
// of Śrīla Prabhupāda's own initiated disciples. Same Vaishnava styling and
|
||||
// 17px base as the other author-facing templates.
|
||||
var discipleWelcomeEmailTpl = template.Must(template.New("discipleWelcome").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>Please accept our humble obeisances.</p>
|
||||
|
||||
<p>It is our great fortune to welcome you to
|
||||
<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a>.
|
||||
We have recognised your account as belonging to
|
||||
{{if .DiscipleName}}<strong>{{.DiscipleName}}</strong>, one of{{else}}one of{{end}}
|
||||
Śrīla Prabhupāda's own initiated disciples.</p>
|
||||
|
||||
<p>Your profile now carries His Divine Grace's picture beside your name, and
|
||||
your name is highlighted in our directory of his disciples — so every devotee
|
||||
who visits knows they are hearing from someone who served Śrīla Prabhupāda
|
||||
personally.</p>
|
||||
|
||||
<p>This whole library exists to preserve and spread Śrīla Prabhupāda's
|
||||
<em>vāṇī</em>. Your presence here — your memories, your realisations, your
|
||||
corrections — is a gift to the devotees who came after. We would be honoured
|
||||
by whatever you care to share.</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 your profile</a>
|
||||
</p>
|
||||
|
||||
<p>If anything about your account needs correcting, simply reply to this
|
||||
email and we will attend to it personally.</p>
|
||||
|
||||
<p style="margin-top:1.5em">Your servants,<br>
|
||||
<strong>The Prabhupāda Says team</strong></p>
|
||||
</div>`))
|
||||
|
||||
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>`))
|
||||
@@ -0,0 +1,25 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// ListShortURLs pages the shortened-URL table for the admin panel.
|
||||
func (s *AdminService) ListShortURLs(ctx context.Context, q string, limit, offset int) ([]model.AdminShortURL, int, error) {
|
||||
return s.repo.ListShortURLs(ctx, q, limit, offset)
|
||||
}
|
||||
|
||||
// ShortURLStats computes the today / this-week / this-month / all-time short-URL
|
||||
// summary (and an optional custom range). The fixed windows are UTC-calendar
|
||||
// based: today from 00:00 UTC, week from Monday 00:00 UTC, month from the 1st.
|
||||
func (s *AdminService) ShortURLStats(ctx context.Context, from, to *time.Time) (*model.ShortURLStats, error) {
|
||||
now := time.Now().UTC()
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
// Monday as the first day of the week (Weekday: Sun=0 … Sat=6).
|
||||
weekStart := todayStart.AddDate(0, 0, -((int(now.Weekday()) + 6) % 7))
|
||||
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
return s.repo.ShortURLStats(ctx, todayStart, weekStart, monthStart, from, to)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Package service — AudioProgressService is a thin passthrough over
|
||||
// AudioProgressRepo. The audio player's resume points carry no server-side
|
||||
// business logic (the frontend owns the schema and the newest-wins merge
|
||||
// is enforced in SQL), so this layer exists only to keep the
|
||||
// handler → service → repository shape consistent with the rest of the API.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
)
|
||||
|
||||
type AudioProgressService struct {
|
||||
repo *repository.AudioProgressRepo
|
||||
}
|
||||
|
||||
func NewAudioProgressService(repo *repository.AudioProgressRepo) *AudioProgressService {
|
||||
return &AudioProgressService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *AudioProgressService) List(ctx context.Context, userID string) ([]model.AudioProgress, error) {
|
||||
return s.repo.ListByUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *AudioProgressService) Upsert(ctx context.Context, userID string, items []model.AudioProgress) error {
|
||||
return s.repo.Upsert(ctx, userID, items)
|
||||
}
|
||||
|
||||
func (s *AudioProgressService) Delete(ctx context.Context, userID, key string) error {
|
||||
return s.repo.Delete(ctx, userID, key)
|
||||
}
|
||||
@@ -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,12 +186,422 @@ 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
|
||||
// the stored value. Mismatch means the user has logged-out-everywhere or
|
||||
// changed password since this JWT was issued → reject.
|
||||
@@ -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,362 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// ListForUser returns the signed-in user's own submissions (newest
|
||||
// first) with status and any admin reply, for the "My feedback" page.
|
||||
func (s *FeedbackService) ListForUser(ctx context.Context, userID string, limit int) ([]model.Feedback, error) {
|
||||
return s.repo.ListByUser(ctx, userID, limit)
|
||||
}
|
||||
|
||||
// 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,885 @@
|
||||
// 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"
|
||||
|
||||
// NotifDiscipleJoined is broadcast to the whole community when an admin
|
||||
// recognises an account as one of Śrīla Prabhupāda's own initiated
|
||||
// disciples. The actor is the disciple, so it renders and links exactly
|
||||
// like the other actor-driven kinds.
|
||||
NotifDiscipleJoined = "disciple_joined"
|
||||
|
||||
// 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},
|
||||
"community": {NotifDiscipleJoined},
|
||||
}
|
||||
|
||||
// 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.TriggerUser(ctx, 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.TriggerUser(ctx, 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.TriggerUser(ctx, 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.TriggerUser(ctx, 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
|
||||
}
|
||||
|
||||
// pushBroadcastDelay paces the per-user push fan-out. Web Push is one HTTP
|
||||
// request per subscription to an external service (FCM/Mozilla/Apple), so a
|
||||
// tight loop over hundreds of devotees would hammer those endpoints and spike
|
||||
// outbound connections. A small gap keeps the broadcast a background trickle.
|
||||
const pushBroadcastDelay = 50 * time.Millisecond
|
||||
|
||||
// BroadcastDiscipleJoined tells the community that `actor` — an account an
|
||||
// admin has just recognised as one of Śrīla Prabhupāda's own initiated
|
||||
// disciples — has joined.
|
||||
//
|
||||
// Two channels, deliberately decoupled by cost:
|
||||
//
|
||||
// - In-app: a single bulk INSERT for every user. Cheap and instant, so it
|
||||
// happens inline and the row is durable the moment this returns.
|
||||
// - Push + realtime: one call per recipient against external services, so it
|
||||
// runs paced. The caller is expected to invoke this on a detached,
|
||||
// time-limited context (not the admin's request context, which dies with
|
||||
// the response).
|
||||
//
|
||||
// No email — announcements are in-app/push only by design; see the
|
||||
// deliverability reasoning in the disciple-link notes.
|
||||
//
|
||||
// Errors from the fan-out are logged, not returned: the announcement is
|
||||
// already durably recorded in-app, and a failed push to one devotee must not
|
||||
// abort the rest.
|
||||
func (s *NotificationService) BroadcastDiscipleJoined(ctx context.Context, actor *model.User) error {
|
||||
if actor == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
written, err := s.notifs.BroadcastToAllUsers(ctx, NotifDiscipleJoined, actor.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("disciple-joined broadcast: in-app rows written",
|
||||
zap.String("actor", actor.Handle), zap.Int64("recipients", written))
|
||||
|
||||
// Everything below is best-effort delivery on top of the durable row.
|
||||
recipients, err := s.notifs.PushRecipientsForKind(ctx, NotifDiscipleJoined, actor.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("disciple-joined broadcast: push recipient lookup failed", zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
if len(recipients) == 0 || s.push == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := actorDisplay(actor)
|
||||
payload := PushPayload{
|
||||
Title: "A direct disciple has joined",
|
||||
Body: name + " — an initiated disciple of Śrīla Prabhupāda — is now on Prabhupāda Says.",
|
||||
URL: s.frontendURL + "/u/" + actor.Handle,
|
||||
Icon: s.iconURL(),
|
||||
Tag: NotifDiscipleJoined,
|
||||
}
|
||||
|
||||
sent := 0
|
||||
for _, uid := range recipients {
|
||||
// Honour cancellation so a shutdown (or the caller's timeout) stops
|
||||
// the fan-out cleanly rather than running to completion detached.
|
||||
if ctx.Err() != nil {
|
||||
s.logger.Warn("disciple-joined broadcast: push fan-out cut short",
|
||||
zap.Int("sent", sent), zap.Int("total", len(recipients)), zap.Error(ctx.Err()))
|
||||
return nil
|
||||
}
|
||||
if s.rt != nil {
|
||||
if err := s.rt.TriggerUser(ctx, uid, "notification", map[string]any{
|
||||
"kind": NotifDiscipleJoined,
|
||||
"actor_name": name,
|
||||
"actor_handle": actor.Handle,
|
||||
}); err != nil {
|
||||
s.logger.Debug("disciple-joined realtime publish failed",
|
||||
zap.String("recipient", uid), zap.Error(err))
|
||||
}
|
||||
}
|
||||
s.push.SendToUser(ctx, uid, payload)
|
||||
sent++
|
||||
time.Sleep(pushBroadcastDelay)
|
||||
}
|
||||
s.logger.Info("disciple-joined broadcast: push fan-out complete",
|
||||
zap.String("actor", actor.Handle), zap.Int("recipients", sent))
|
||||
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>`))
|
||||
+23
-47
@@ -3,19 +3,16 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// Plan caps for the social features. Updates here propagate everywhere via
|
||||
// EnforceBookmarkLimit / EnforceCollectionLimit.
|
||||
// Plan identifiers. The numeric caps live in model.ResolveLimits (so the self
|
||||
// user payload and these enforcers agree), and per-user admin overrides take
|
||||
// precedence over the plan default.
|
||||
const (
|
||||
PlanFree = "free"
|
||||
PlanPremium = "premium"
|
||||
|
||||
freeBookmarkLimit = 1
|
||||
premiumBookmarkLimit = 5
|
||||
|
||||
freeCollectionPerVisibilityLimit = 5 // 5 public AND 5 private separately
|
||||
// Premium has no collection cap.
|
||||
)
|
||||
|
||||
// ErrPlanLimit signals that a free-plan limit blocks the requested action.
|
||||
@@ -38,50 +35,29 @@ func (e *PlanLimitError) Error() string {
|
||||
func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit }
|
||||
|
||||
// EnforceBookmarkLimit returns nil if the user is allowed to create one more
|
||||
// bookmark, or a PlanLimitError otherwise.
|
||||
func EnforceBookmarkLimit(plan string, currentCount int) error {
|
||||
limit := bookmarkLimitFor(plan)
|
||||
if currentCount >= limit {
|
||||
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: plan}
|
||||
// bookmark, or a PlanLimitError otherwise. The cap is the user's effective
|
||||
// bookmark limit (plan default, or an admin override). A negative cap means
|
||||
// unlimited.
|
||||
func EnforceBookmarkLimit(u *model.User, currentCount int) error {
|
||||
limit := u.EffectiveLimits().Bookmarks
|
||||
if limit >= 0 && currentCount >= limit {
|
||||
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: u.Plan}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnforceCollectionLimit returns nil if the user is allowed to create one
|
||||
// more collection of the given visibility.
|
||||
//
|
||||
// `isPublic=true` → checks against public-collection cap
|
||||
// `isPublic=false` → checks against private-collection cap
|
||||
// Premium users have no cap → always nil.
|
||||
func EnforceCollectionLimit(plan string, isPublic bool, currentCount int) error {
|
||||
if plan == PlanPremium {
|
||||
return nil
|
||||
// EnforceCollectionLimit returns nil if the user is allowed to create one more
|
||||
// collection of the given visibility. The cap is the user's effective
|
||||
// public/private collection limit (plan default, or an admin override); a
|
||||
// negative cap means unlimited.
|
||||
func EnforceCollectionLimit(u *model.User, isPublic bool, currentCount int) error {
|
||||
l := u.EffectiveLimits()
|
||||
limit, resource := l.PrivateCollections, "private collections"
|
||||
if isPublic {
|
||||
limit, resource = l.PublicCollections, "public collections"
|
||||
}
|
||||
limit := freeCollectionPerVisibilityLimit
|
||||
if currentCount >= limit {
|
||||
resource := "private collections"
|
||||
if isPublic {
|
||||
resource = "public collections"
|
||||
}
|
||||
return &PlanLimitError{Resource: resource, Limit: limit, Plan: plan}
|
||||
if limit >= 0 && currentCount >= limit {
|
||||
return &PlanLimitError{Resource: resource, Limit: limit, Plan: u.Plan}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bookmarkLimitFor(plan string) int {
|
||||
if plan == PlanPremium {
|
||||
return premiumBookmarkLimit
|
||||
}
|
||||
return freeBookmarkLimit
|
||||
}
|
||||
|
||||
// BookmarkLimitFor returns the cap (for showing "X / Y used" in the UI).
|
||||
func BookmarkLimitFor(plan string) int { return bookmarkLimitFor(plan) }
|
||||
|
||||
// CollectionLimitFor returns the per-visibility cap. -1 means unlimited.
|
||||
func CollectionLimitFor(plan string) int {
|
||||
if plan == PlanPremium {
|
||||
return -1
|
||||
}
|
||||
return freeCollectionPerVisibilityLimit
|
||||
}
|
||||
|
||||
@@ -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,251 @@
|
||||
// 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
|
||||
users *repository.UserRepo
|
||||
notif *NotificationService
|
||||
logger *zap.Logger
|
||||
sadhnaURL string
|
||||
}
|
||||
|
||||
func NewReminderService(repo *repository.ReminderRepo, prefs *repository.NotificationPrefsRepo, users *repository.UserRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService {
|
||||
return &ReminderService{
|
||||
repo: repo,
|
||||
prefs: prefs,
|
||||
users: users,
|
||||
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
|
||||
}
|
||||
if err := s.repo.SetTimezone(ctx, userID, tz); err != nil {
|
||||
return err
|
||||
}
|
||||
// Best-effort: backfill the account country from the timezone when it's
|
||||
// still unset (geo-IP at login is the primary source; SetCountryIfEmpty
|
||||
// never overwrites it). A no-op when the zone isn't in the table.
|
||||
if s.users != nil {
|
||||
if cc := tzCountry[tz]; cc != "" {
|
||||
if err := s.users.SetCountryIfEmpty(ctx, userID, cc); err != nil {
|
||||
s.logger.Warn("backfill country from timezone failed",
|
||||
zap.String("user", userID), zap.String("country", cc), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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,486 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
// IncomingRequests returns the pending follow requests addressed to userID
|
||||
// (the caller) — the "Friend Requests" tab. Caller-scoped, so it takes the id
|
||||
// directly rather than a handle.
|
||||
func (s *SocialGraphService) IncomingRequests(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||
return s.follows.ListIncomingRequests(ctx, userID, 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>`))
|
||||
@@ -31,19 +31,28 @@ var (
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PostService struct {
|
||||
posts *repository.PostRepo
|
||||
r2 *repository.R2
|
||||
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,82 @@ 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
|
||||
}
|
||||
|
||||
// ListMineKeyset is the owner's scroll feed via keyset pagination (posts older
|
||||
// than the cursor, newest-first). Same hydration as ListMine; no total.
|
||||
func (s *PostService) ListMineKeyset(ctx context.Context, userID string, beforeAt time.Time, beforeID string, limit int) ([]model.Post, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
posts, err := s.posts.ListByUserKeyset(ctx, userID, beforeAt, beforeID, limit)
|
||||
if err != nil {
|
||||
return nil, 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, err
|
||||
}
|
||||
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return posts, 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.
|
||||
@@ -141,7 +538,7 @@ func (s *BookmarkService) Create(ctx context.Context, userID, sourceURL, sourceT
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := EnforceBookmarkLimit(u.Plan, current); err != nil {
|
||||
if err := EnforceBookmarkLimit(u, current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -205,7 +602,7 @@ func (s *CollectionService) Create(ctx context.Context, userID, title string, de
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := EnforceCollectionLimit(u.Plan, isPublic, current); err != nil {
|
||||
if err := EnforceCollectionLimit(u, isPublic, current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.collections.Create(ctx, userID, title, description, coverURL, isPublic)
|
||||
@@ -231,7 +628,7 @@ func (s *CollectionService) Update(ctx context.Context, id, userID string, title
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := EnforceCollectionLimit(u.Plan, *isPublic, current); err != nil {
|
||||
if err := EnforceCollectionLimit(u, *isPublic, current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -320,6 +717,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
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Code generated from the IANA tz database (zone.tab). DO NOT EDIT by hand.
|
||||
// Maps a canonical IANA timezone to its ISO-3166-1 alpha-2 country code.
|
||||
package service
|
||||
|
||||
var tzCountry = map[string]string{
|
||||
"Africa/Abidjan": "CI",
|
||||
"Africa/Accra": "GH",
|
||||
"Africa/Addis_Ababa": "ET",
|
||||
"Africa/Algiers": "DZ",
|
||||
"Africa/Asmara": "ER",
|
||||
"Africa/Bamako": "ML",
|
||||
"Africa/Bangui": "CF",
|
||||
"Africa/Banjul": "GM",
|
||||
"Africa/Bissau": "GW",
|
||||
"Africa/Blantyre": "MW",
|
||||
"Africa/Brazzaville": "CG",
|
||||
"Africa/Bujumbura": "BI",
|
||||
"Africa/Cairo": "EG",
|
||||
"Africa/Casablanca": "MA",
|
||||
"Africa/Ceuta": "ES",
|
||||
"Africa/Conakry": "GN",
|
||||
"Africa/Dakar": "SN",
|
||||
"Africa/Dar_es_Salaam": "TZ",
|
||||
"Africa/Djibouti": "DJ",
|
||||
"Africa/Douala": "CM",
|
||||
"Africa/El_Aaiun": "EH",
|
||||
"Africa/Freetown": "SL",
|
||||
"Africa/Gaborone": "BW",
|
||||
"Africa/Harare": "ZW",
|
||||
"Africa/Johannesburg": "ZA",
|
||||
"Africa/Juba": "SS",
|
||||
"Africa/Kampala": "UG",
|
||||
"Africa/Khartoum": "SD",
|
||||
"Africa/Kigali": "RW",
|
||||
"Africa/Kinshasa": "CD",
|
||||
"Africa/Lagos": "NG",
|
||||
"Africa/Libreville": "GA",
|
||||
"Africa/Lome": "TG",
|
||||
"Africa/Luanda": "AO",
|
||||
"Africa/Lubumbashi": "CD",
|
||||
"Africa/Lusaka": "ZM",
|
||||
"Africa/Malabo": "GQ",
|
||||
"Africa/Maputo": "MZ",
|
||||
"Africa/Maseru": "LS",
|
||||
"Africa/Mbabane": "SZ",
|
||||
"Africa/Mogadishu": "SO",
|
||||
"Africa/Monrovia": "LR",
|
||||
"Africa/Nairobi": "KE",
|
||||
"Africa/Ndjamena": "TD",
|
||||
"Africa/Niamey": "NE",
|
||||
"Africa/Nouakchott": "MR",
|
||||
"Africa/Ouagadougou": "BF",
|
||||
"Africa/Porto-Novo": "BJ",
|
||||
"Africa/Sao_Tome": "ST",
|
||||
"Africa/Tripoli": "LY",
|
||||
"Africa/Tunis": "TN",
|
||||
"Africa/Windhoek": "NA",
|
||||
"America/Adak": "US",
|
||||
"America/Anchorage": "US",
|
||||
"America/Anguilla": "AI",
|
||||
"America/Antigua": "AG",
|
||||
"America/Araguaina": "BR",
|
||||
"America/Argentina/Buenos_Aires": "AR",
|
||||
"America/Argentina/Catamarca": "AR",
|
||||
"America/Argentina/Cordoba": "AR",
|
||||
"America/Argentina/Jujuy": "AR",
|
||||
"America/Argentina/La_Rioja": "AR",
|
||||
"America/Argentina/Mendoza": "AR",
|
||||
"America/Argentina/Rio_Gallegos": "AR",
|
||||
"America/Argentina/Salta": "AR",
|
||||
"America/Argentina/San_Juan": "AR",
|
||||
"America/Argentina/San_Luis": "AR",
|
||||
"America/Argentina/Tucuman": "AR",
|
||||
"America/Argentina/Ushuaia": "AR",
|
||||
"America/Aruba": "AW",
|
||||
"America/Asuncion": "PY",
|
||||
"America/Atikokan": "CA",
|
||||
"America/Bahia": "BR",
|
||||
"America/Bahia_Banderas": "MX",
|
||||
"America/Barbados": "BB",
|
||||
"America/Belem": "BR",
|
||||
"America/Belize": "BZ",
|
||||
"America/Blanc-Sablon": "CA",
|
||||
"America/Boa_Vista": "BR",
|
||||
"America/Bogota": "CO",
|
||||
"America/Boise": "US",
|
||||
"America/Cambridge_Bay": "CA",
|
||||
"America/Campo_Grande": "BR",
|
||||
"America/Cancun": "MX",
|
||||
"America/Caracas": "VE",
|
||||
"America/Cayenne": "GF",
|
||||
"America/Cayman": "KY",
|
||||
"America/Chicago": "US",
|
||||
"America/Chihuahua": "MX",
|
||||
"America/Ciudad_Juarez": "MX",
|
||||
"America/Costa_Rica": "CR",
|
||||
"America/Coyhaique": "CL",
|
||||
"America/Creston": "CA",
|
||||
"America/Cuiaba": "BR",
|
||||
"America/Curacao": "CW",
|
||||
"America/Danmarkshavn": "GL",
|
||||
"America/Dawson": "CA",
|
||||
"America/Dawson_Creek": "CA",
|
||||
"America/Denver": "US",
|
||||
"America/Detroit": "US",
|
||||
"America/Dominica": "DM",
|
||||
"America/Edmonton": "CA",
|
||||
"America/Eirunepe": "BR",
|
||||
"America/El_Salvador": "SV",
|
||||
"America/Fort_Nelson": "CA",
|
||||
"America/Fortaleza": "BR",
|
||||
"America/Glace_Bay": "CA",
|
||||
"America/Goose_Bay": "CA",
|
||||
"America/Grand_Turk": "TC",
|
||||
"America/Grenada": "GD",
|
||||
"America/Guadeloupe": "GP",
|
||||
"America/Guatemala": "GT",
|
||||
"America/Guayaquil": "EC",
|
||||
"America/Guyana": "GY",
|
||||
"America/Halifax": "CA",
|
||||
"America/Havana": "CU",
|
||||
"America/Hermosillo": "MX",
|
||||
"America/Indiana/Indianapolis": "US",
|
||||
"America/Indiana/Knox": "US",
|
||||
"America/Indiana/Marengo": "US",
|
||||
"America/Indiana/Petersburg": "US",
|
||||
"America/Indiana/Tell_City": "US",
|
||||
"America/Indiana/Vevay": "US",
|
||||
"America/Indiana/Vincennes": "US",
|
||||
"America/Indiana/Winamac": "US",
|
||||
"America/Inuvik": "CA",
|
||||
"America/Iqaluit": "CA",
|
||||
"America/Jamaica": "JM",
|
||||
"America/Juneau": "US",
|
||||
"America/Kentucky/Louisville": "US",
|
||||
"America/Kentucky/Monticello": "US",
|
||||
"America/Kralendijk": "BQ",
|
||||
"America/La_Paz": "BO",
|
||||
"America/Lima": "PE",
|
||||
"America/Los_Angeles": "US",
|
||||
"America/Lower_Princes": "SX",
|
||||
"America/Maceio": "BR",
|
||||
"America/Managua": "NI",
|
||||
"America/Manaus": "BR",
|
||||
"America/Marigot": "MF",
|
||||
"America/Martinique": "MQ",
|
||||
"America/Matamoros": "MX",
|
||||
"America/Mazatlan": "MX",
|
||||
"America/Menominee": "US",
|
||||
"America/Merida": "MX",
|
||||
"America/Metlakatla": "US",
|
||||
"America/Mexico_City": "MX",
|
||||
"America/Miquelon": "PM",
|
||||
"America/Moncton": "CA",
|
||||
"America/Monterrey": "MX",
|
||||
"America/Montevideo": "UY",
|
||||
"America/Montserrat": "MS",
|
||||
"America/Nassau": "BS",
|
||||
"America/New_York": "US",
|
||||
"America/Nome": "US",
|
||||
"America/Noronha": "BR",
|
||||
"America/North_Dakota/Beulah": "US",
|
||||
"America/North_Dakota/Center": "US",
|
||||
"America/North_Dakota/New_Salem": "US",
|
||||
"America/Nuuk": "GL",
|
||||
"America/Ojinaga": "MX",
|
||||
"America/Panama": "PA",
|
||||
"America/Paramaribo": "SR",
|
||||
"America/Phoenix": "US",
|
||||
"America/Port-au-Prince": "HT",
|
||||
"America/Port_of_Spain": "TT",
|
||||
"America/Porto_Velho": "BR",
|
||||
"America/Puerto_Rico": "PR",
|
||||
"America/Punta_Arenas": "CL",
|
||||
"America/Rankin_Inlet": "CA",
|
||||
"America/Recife": "BR",
|
||||
"America/Regina": "CA",
|
||||
"America/Resolute": "CA",
|
||||
"America/Rio_Branco": "BR",
|
||||
"America/Santarem": "BR",
|
||||
"America/Santiago": "CL",
|
||||
"America/Santo_Domingo": "DO",
|
||||
"America/Sao_Paulo": "BR",
|
||||
"America/Scoresbysund": "GL",
|
||||
"America/Sitka": "US",
|
||||
"America/St_Barthelemy": "BL",
|
||||
"America/St_Johns": "CA",
|
||||
"America/St_Kitts": "KN",
|
||||
"America/St_Lucia": "LC",
|
||||
"America/St_Thomas": "VI",
|
||||
"America/St_Vincent": "VC",
|
||||
"America/Swift_Current": "CA",
|
||||
"America/Tegucigalpa": "HN",
|
||||
"America/Thule": "GL",
|
||||
"America/Tijuana": "MX",
|
||||
"America/Toronto": "CA",
|
||||
"America/Tortola": "VG",
|
||||
"America/Vancouver": "CA",
|
||||
"America/Whitehorse": "CA",
|
||||
"America/Winnipeg": "CA",
|
||||
"America/Yakutat": "US",
|
||||
"Antarctica/Casey": "AQ",
|
||||
"Antarctica/Davis": "AQ",
|
||||
"Antarctica/DumontDUrville": "AQ",
|
||||
"Antarctica/Macquarie": "AU",
|
||||
"Antarctica/Mawson": "AQ",
|
||||
"Antarctica/McMurdo": "AQ",
|
||||
"Antarctica/Palmer": "AQ",
|
||||
"Antarctica/Rothera": "AQ",
|
||||
"Antarctica/Syowa": "AQ",
|
||||
"Antarctica/Troll": "AQ",
|
||||
"Antarctica/Vostok": "AQ",
|
||||
"Arctic/Longyearbyen": "SJ",
|
||||
"Asia/Aden": "YE",
|
||||
"Asia/Almaty": "KZ",
|
||||
"Asia/Amman": "JO",
|
||||
"Asia/Anadyr": "RU",
|
||||
"Asia/Aqtau": "KZ",
|
||||
"Asia/Aqtobe": "KZ",
|
||||
"Asia/Ashgabat": "TM",
|
||||
"Asia/Atyrau": "KZ",
|
||||
"Asia/Baghdad": "IQ",
|
||||
"Asia/Bahrain": "BH",
|
||||
"Asia/Baku": "AZ",
|
||||
"Asia/Bangkok": "TH",
|
||||
"Asia/Barnaul": "RU",
|
||||
"Asia/Beirut": "LB",
|
||||
"Asia/Bishkek": "KG",
|
||||
"Asia/Brunei": "BN",
|
||||
"Asia/Chita": "RU",
|
||||
"Asia/Colombo": "LK",
|
||||
"Asia/Damascus": "SY",
|
||||
"Asia/Dhaka": "BD",
|
||||
"Asia/Dili": "TL",
|
||||
"Asia/Dubai": "AE",
|
||||
"Asia/Dushanbe": "TJ",
|
||||
"Asia/Famagusta": "CY",
|
||||
"Asia/Gaza": "PS",
|
||||
"Asia/Hebron": "PS",
|
||||
"Asia/Ho_Chi_Minh": "VN",
|
||||
"Asia/Hong_Kong": "HK",
|
||||
"Asia/Hovd": "MN",
|
||||
"Asia/Irkutsk": "RU",
|
||||
"Asia/Jakarta": "ID",
|
||||
"Asia/Jayapura": "ID",
|
||||
"Asia/Jerusalem": "IL",
|
||||
"Asia/Kabul": "AF",
|
||||
"Asia/Kamchatka": "RU",
|
||||
"Asia/Karachi": "PK",
|
||||
"Asia/Kathmandu": "NP",
|
||||
"Asia/Khandyga": "RU",
|
||||
"Asia/Kolkata": "IN",
|
||||
"Asia/Krasnoyarsk": "RU",
|
||||
"Asia/Kuala_Lumpur": "MY",
|
||||
"Asia/Kuching": "MY",
|
||||
"Asia/Kuwait": "KW",
|
||||
"Asia/Macau": "MO",
|
||||
"Asia/Magadan": "RU",
|
||||
"Asia/Makassar": "ID",
|
||||
"Asia/Manila": "PH",
|
||||
"Asia/Muscat": "OM",
|
||||
"Asia/Nicosia": "CY",
|
||||
"Asia/Novokuznetsk": "RU",
|
||||
"Asia/Novosibirsk": "RU",
|
||||
"Asia/Omsk": "RU",
|
||||
"Asia/Oral": "KZ",
|
||||
"Asia/Phnom_Penh": "KH",
|
||||
"Asia/Pontianak": "ID",
|
||||
"Asia/Pyongyang": "KP",
|
||||
"Asia/Qatar": "QA",
|
||||
"Asia/Qostanay": "KZ",
|
||||
"Asia/Qyzylorda": "KZ",
|
||||
"Asia/Riyadh": "SA",
|
||||
"Asia/Sakhalin": "RU",
|
||||
"Asia/Samarkand": "UZ",
|
||||
"Asia/Seoul": "KR",
|
||||
"Asia/Shanghai": "CN",
|
||||
"Asia/Singapore": "SG",
|
||||
"Asia/Srednekolymsk": "RU",
|
||||
"Asia/Taipei": "TW",
|
||||
"Asia/Tashkent": "UZ",
|
||||
"Asia/Tbilisi": "GE",
|
||||
"Asia/Tehran": "IR",
|
||||
"Asia/Thimphu": "BT",
|
||||
"Asia/Tokyo": "JP",
|
||||
"Asia/Tomsk": "RU",
|
||||
"Asia/Ulaanbaatar": "MN",
|
||||
"Asia/Urumqi": "CN",
|
||||
"Asia/Ust-Nera": "RU",
|
||||
"Asia/Vientiane": "LA",
|
||||
"Asia/Vladivostok": "RU",
|
||||
"Asia/Yakutsk": "RU",
|
||||
"Asia/Yangon": "MM",
|
||||
"Asia/Yekaterinburg": "RU",
|
||||
"Asia/Yerevan": "AM",
|
||||
"Atlantic/Azores": "PT",
|
||||
"Atlantic/Bermuda": "BM",
|
||||
"Atlantic/Canary": "ES",
|
||||
"Atlantic/Cape_Verde": "CV",
|
||||
"Atlantic/Faroe": "FO",
|
||||
"Atlantic/Madeira": "PT",
|
||||
"Atlantic/Reykjavik": "IS",
|
||||
"Atlantic/South_Georgia": "GS",
|
||||
"Atlantic/St_Helena": "SH",
|
||||
"Atlantic/Stanley": "FK",
|
||||
"Australia/Adelaide": "AU",
|
||||
"Australia/Brisbane": "AU",
|
||||
"Australia/Broken_Hill": "AU",
|
||||
"Australia/Darwin": "AU",
|
||||
"Australia/Eucla": "AU",
|
||||
"Australia/Hobart": "AU",
|
||||
"Australia/Lindeman": "AU",
|
||||
"Australia/Lord_Howe": "AU",
|
||||
"Australia/Melbourne": "AU",
|
||||
"Australia/Perth": "AU",
|
||||
"Australia/Sydney": "AU",
|
||||
"Europe/Amsterdam": "NL",
|
||||
"Europe/Andorra": "AD",
|
||||
"Europe/Astrakhan": "RU",
|
||||
"Europe/Athens": "GR",
|
||||
"Europe/Belgrade": "RS",
|
||||
"Europe/Berlin": "DE",
|
||||
"Europe/Bratislava": "SK",
|
||||
"Europe/Brussels": "BE",
|
||||
"Europe/Bucharest": "RO",
|
||||
"Europe/Budapest": "HU",
|
||||
"Europe/Busingen": "DE",
|
||||
"Europe/Chisinau": "MD",
|
||||
"Europe/Copenhagen": "DK",
|
||||
"Europe/Dublin": "IE",
|
||||
"Europe/Gibraltar": "GI",
|
||||
"Europe/Guernsey": "GG",
|
||||
"Europe/Helsinki": "FI",
|
||||
"Europe/Isle_of_Man": "IM",
|
||||
"Europe/Istanbul": "TR",
|
||||
"Europe/Jersey": "JE",
|
||||
"Europe/Kaliningrad": "RU",
|
||||
"Europe/Kirov": "RU",
|
||||
"Europe/Kyiv": "UA",
|
||||
"Europe/Lisbon": "PT",
|
||||
"Europe/Ljubljana": "SI",
|
||||
"Europe/London": "GB",
|
||||
"Europe/Luxembourg": "LU",
|
||||
"Europe/Madrid": "ES",
|
||||
"Europe/Malta": "MT",
|
||||
"Europe/Mariehamn": "AX",
|
||||
"Europe/Minsk": "BY",
|
||||
"Europe/Monaco": "MC",
|
||||
"Europe/Moscow": "RU",
|
||||
"Europe/Oslo": "NO",
|
||||
"Europe/Paris": "FR",
|
||||
"Europe/Podgorica": "ME",
|
||||
"Europe/Prague": "CZ",
|
||||
"Europe/Riga": "LV",
|
||||
"Europe/Rome": "IT",
|
||||
"Europe/Samara": "RU",
|
||||
"Europe/San_Marino": "SM",
|
||||
"Europe/Sarajevo": "BA",
|
||||
"Europe/Saratov": "RU",
|
||||
"Europe/Simferopol": "UA",
|
||||
"Europe/Skopje": "MK",
|
||||
"Europe/Sofia": "BG",
|
||||
"Europe/Stockholm": "SE",
|
||||
"Europe/Tallinn": "EE",
|
||||
"Europe/Tirane": "AL",
|
||||
"Europe/Ulyanovsk": "RU",
|
||||
"Europe/Vaduz": "LI",
|
||||
"Europe/Vatican": "VA",
|
||||
"Europe/Vienna": "AT",
|
||||
"Europe/Vilnius": "LT",
|
||||
"Europe/Volgograd": "RU",
|
||||
"Europe/Warsaw": "PL",
|
||||
"Europe/Zagreb": "HR",
|
||||
"Europe/Zurich": "CH",
|
||||
"Indian/Antananarivo": "MG",
|
||||
"Indian/Chagos": "IO",
|
||||
"Indian/Christmas": "CX",
|
||||
"Indian/Cocos": "CC",
|
||||
"Indian/Comoro": "KM",
|
||||
"Indian/Kerguelen": "TF",
|
||||
"Indian/Mahe": "SC",
|
||||
"Indian/Maldives": "MV",
|
||||
"Indian/Mauritius": "MU",
|
||||
"Indian/Mayotte": "YT",
|
||||
"Indian/Reunion": "RE",
|
||||
"Pacific/Apia": "WS",
|
||||
"Pacific/Auckland": "NZ",
|
||||
"Pacific/Bougainville": "PG",
|
||||
"Pacific/Chatham": "NZ",
|
||||
"Pacific/Chuuk": "FM",
|
||||
"Pacific/Easter": "CL",
|
||||
"Pacific/Efate": "VU",
|
||||
"Pacific/Fakaofo": "TK",
|
||||
"Pacific/Fiji": "FJ",
|
||||
"Pacific/Funafuti": "TV",
|
||||
"Pacific/Galapagos": "EC",
|
||||
"Pacific/Gambier": "PF",
|
||||
"Pacific/Guadalcanal": "SB",
|
||||
"Pacific/Guam": "GU",
|
||||
"Pacific/Honolulu": "US",
|
||||
"Pacific/Kanton": "KI",
|
||||
"Pacific/Kiritimati": "KI",
|
||||
"Pacific/Kosrae": "FM",
|
||||
"Pacific/Kwajalein": "MH",
|
||||
"Pacific/Majuro": "MH",
|
||||
"Pacific/Marquesas": "PF",
|
||||
"Pacific/Midway": "UM",
|
||||
"Pacific/Nauru": "NR",
|
||||
"Pacific/Niue": "NU",
|
||||
"Pacific/Norfolk": "NF",
|
||||
"Pacific/Noumea": "NC",
|
||||
"Pacific/Pago_Pago": "AS",
|
||||
"Pacific/Palau": "PW",
|
||||
"Pacific/Pitcairn": "PN",
|
||||
"Pacific/Pohnpei": "FM",
|
||||
"Pacific/Port_Moresby": "PG",
|
||||
"Pacific/Rarotonga": "CK",
|
||||
"Pacific/Saipan": "MP",
|
||||
"Pacific/Tahiti": "PF",
|
||||
"Pacific/Tarawa": "KI",
|
||||
"Pacific/Tongatapu": "TO",
|
||||
"Pacific/Wake": "UM",
|
||||
"Pacific/Wallis": "WF",
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
@@ -35,28 +36,164 @@ 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
|
||||
Bio *string
|
||||
Country *string
|
||||
Sampradaya *string
|
||||
Gender *string
|
||||
Initiated *bool
|
||||
InitiatedAt *string
|
||||
DailyChantingRounds *int
|
||||
PreferredChantingTime *string
|
||||
PeakActiveTime *string
|
||||
}
|
||||
|
||||
// MaxBioRunes caps the profile bio. Counted in runes so the limit means the
|
||||
// same thing in every script. Keep in sync with the editor's counter.
|
||||
const MaxBioRunes = 200
|
||||
|
||||
// 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.Bio != nil {
|
||||
// "" clears. Counted in runes, not bytes, so a devotee writing in
|
||||
// Devanāgarī gets the same 200 characters as a Latin-script writer
|
||||
// (len() would cut them off after ~66). Collapse the CRLF that
|
||||
// Windows browsers submit so the count matches what the editor shows.
|
||||
b := strings.TrimSpace(strings.ReplaceAll(*in.Bio, "\r\n", "\n"))
|
||||
if utf8.RuneCountInString(b) > MaxBioRunes {
|
||||
return nil, fmt.Errorf("bio too long (max %d characters)", MaxBioRunes)
|
||||
}
|
||||
p.Bio = &b
|
||||
}
|
||||
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,235 @@
|
||||
// Package service — VaniService powers "Vāṇī of the Day": one piece of content
|
||||
// (a prose chapter, a verse, or a transcript/lecture) surfaced on the home page
|
||||
// each day. The pick is chosen once per UTC day and reused for everyone; the
|
||||
// picker avoids anything served in the last 90 days.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const vaniNoRepeatDays = 90
|
||||
|
||||
type VaniService struct {
|
||||
repo *repository.VaniRepo
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewVaniService(repo *repository.VaniRepo, logger *zap.Logger) *VaniService {
|
||||
return &VaniService{repo: repo, logger: logger}
|
||||
}
|
||||
|
||||
// Today returns the current day's pick, choosing (and persisting) one on the
|
||||
// first call of the day. Returns (nil, nil) only if there's genuinely no
|
||||
// content to pick.
|
||||
func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
|
||||
now := time.Now().UTC()
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if existing, err := s.repo.GetForDay(ctx, day); err != nil {
|
||||
return nil, err
|
||||
} else if existing != nil {
|
||||
return &existing.Payload, nil
|
||||
}
|
||||
|
||||
// Build the 90-day exclusion set, bucketed by kind.
|
||||
recent, err := s.repo.RecentRefIDs(ctx, day.AddDate(0, 0, -vaniNoRepeatDays))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exclude := map[string][]string{}
|
||||
for _, rid := range recent {
|
||||
if kind, hex, ok := splitRef(rid); ok {
|
||||
exclude[kind] = append(exclude[kind], hex)
|
||||
}
|
||||
}
|
||||
|
||||
pick := s.choose(ctx, exclude, day)
|
||||
if pick == nil {
|
||||
// Everything in some kind exhausted the 90-day window? Relax it.
|
||||
pick = s.choose(ctx, map[string][]string{}, day)
|
||||
}
|
||||
if pick == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err := s.repo.Insert(ctx, pick); err != nil {
|
||||
s.logger.Warn("vani: insert failed", zap.Error(err))
|
||||
}
|
||||
// Re-read so a concurrent writer's row (ON CONFLICT DO NOTHING) wins
|
||||
// consistently for everyone.
|
||||
if stored, err := s.repo.GetForDay(ctx, day); err == nil && stored != nil {
|
||||
return &stored.Payload, nil
|
||||
}
|
||||
return &pick.Payload, nil
|
||||
}
|
||||
|
||||
// choose tries the three content kinds in random order and returns the first
|
||||
// successful sample, so each day's pick varies across content types.
|
||||
func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick {
|
||||
kinds := []string{"verse", "prose", "transcript"}
|
||||
rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] })
|
||||
|
||||
for _, kind := range kinds {
|
||||
switch kind {
|
||||
case "verse":
|
||||
if v, err := s.repo.SampleVerse(ctx, exclude["verse"]); err == nil && v != nil {
|
||||
return versePick(v, day)
|
||||
}
|
||||
case "prose":
|
||||
if p, err := s.repo.SampleProse(ctx, exclude["prose"]); err == nil && p != nil {
|
||||
return prosePick(p, s.repo.BookTitle(ctx, p.Book), day)
|
||||
}
|
||||
case "transcript":
|
||||
if t, err := s.repo.SampleTranscript(ctx, exclude["transcript"]); err == nil && t != nil {
|
||||
return transcriptPick(t, day)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Payload builders ---
|
||||
|
||||
func versePick(v *repository.VerseDoc, day time.Time) *model.VaniPick {
|
||||
title := v.Reference
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("%s %d.%d", strings.ToUpper(v.Book), v.Chapter, v.Verse)
|
||||
}
|
||||
return &model.VaniPick{
|
||||
Day: day,
|
||||
Kind: "verse",
|
||||
RefID: "verse:" + v.ID.Hex(),
|
||||
Payload: model.VaniPayload{
|
||||
Kind: "verse",
|
||||
Label: "Verse",
|
||||
Title: title,
|
||||
Subtitle: excerpt(v.Translation, 140),
|
||||
Href: verseURL(v.Book, v.Division, v.Chapter, v.Verse),
|
||||
BookTitle: v.BookTitle,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func prosePick(p *repository.ProseChapterDoc, bookTitle string, day time.Time) *model.VaniPick {
|
||||
first := ""
|
||||
if len(p.Paragraphs) > 0 {
|
||||
first = p.Paragraphs[0]
|
||||
}
|
||||
return &model.VaniPick{
|
||||
Day: day,
|
||||
Kind: "prose",
|
||||
RefID: "prose:" + p.ID.Hex(),
|
||||
Payload: model.VaniPayload{
|
||||
Kind: "prose",
|
||||
Label: "Chapter",
|
||||
Title: p.Title,
|
||||
Subtitle: excerpt(first, 140),
|
||||
Href: proseURL(p.Book, p.Volume, p.Type, p.Slug),
|
||||
BookTitle: bookTitle,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func transcriptPick(t *repository.TranscriptionDoc, day time.Time) *model.VaniPick {
|
||||
label := "Lecture"
|
||||
if t.Type == "conversation" {
|
||||
label = "Conversation"
|
||||
}
|
||||
date := ""
|
||||
if t.Date != nil {
|
||||
date = *t.Date
|
||||
}
|
||||
return &model.VaniPick{
|
||||
Day: day,
|
||||
Kind: "transcript",
|
||||
RefID: "transcript:" + t.ID.Hex(),
|
||||
Payload: model.VaniPayload{
|
||||
Kind: "transcript",
|
||||
Label: label,
|
||||
Title: t.Name,
|
||||
Href: "/transcriptions/" + t.Slug,
|
||||
Date: date,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- URL builders (mirror cmd/indexer buildVerseURL / prose URL logic) ---
|
||||
|
||||
func verseURL(book string, division *string, chapter, verse int) string {
|
||||
switch book {
|
||||
case "bg":
|
||||
return fmt.Sprintf("/books/bg/%d/%d", chapter, verse)
|
||||
case "sb":
|
||||
if division != nil {
|
||||
return fmt.Sprintf("/books/sb/%s/%d/%d", *division, chapter, verse)
|
||||
}
|
||||
return fmt.Sprintf("/books/sb/%d/%d", chapter, verse)
|
||||
case "cc":
|
||||
if division != nil {
|
||||
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)
|
||||
default:
|
||||
return fmt.Sprintf("/books/%s/%d/%d", book, chapter, verse)
|
||||
}
|
||||
}
|
||||
|
||||
func proseURL(book string, volume int, docType, slug string) string {
|
||||
switch {
|
||||
case book == "kb":
|
||||
return fmt.Sprintf("/books/kb/%d/%s", volume, slug)
|
||||
case docType == "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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func splitRef(ref string) (kind, hex string, ok bool) {
|
||||
if i := strings.IndexByte(ref, ':'); i > 0 {
|
||||
return ref[:i], ref[i+1:], true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var htmlTagRe = regexp.MustCompile(`<[^>]*>`)
|
||||
var wsRe = regexp.MustCompile(`\s+`)
|
||||
|
||||
// excerpt strips HTML, collapses whitespace, and truncates to n runes with an
|
||||
// ellipsis — a clean one-line teaser for the CTA.
|
||||
func excerpt(html string, n int) string {
|
||||
s := htmlTagRe.ReplaceAllString(html, "")
|
||||
s = strings.TrimSpace(wsRe.ReplaceAllString(s, " "))
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return strings.TrimSpace(string(r[:n])) + "…"
|
||||
}
|
||||
@@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user