78 Commits
Author SHA1 Message Date
Krispire AdminandClaude Opus 5.5 d76505772a Admin-managed feedback categories
Categories move from a hard-coded list to a feedback_categories table
(migration 0062), seeded with the four existing keys, labels and
descriptions so production feedback is untouched; any other key already in
use gets an archived category. Keys are fixed, so renames relabel past
feedback. Archive hides a category from the form; only unused categories
can be deleted. Submissions are validated against active categories, and
feedback responses carry category_label.

New: GET /feedback/categories (public); /admin/feedback/categories CRUD +
reorder behind the new feedback:categories permission (granted to Admin).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:47:09 +05:30
Krispire Admin fa0255e9cd quiz grouping 2026-10-18 13:02:13 +05:30
Krispire Admin eb02d08f14 unique quiz constraint 2026-10-16 15:23:06 +05:30
Krispire Admin b1f78b1655 quiz resetc 2026-10-15 20:59:53 +05:30
Krispire Admin 4b38047c26 feat: quiz and rbac 2026-10-15 19:43:17 +05:30
Krispire Admin 7d7ba2a002 feedback formatter 2026-10-09 06:40:13 +05:30
Krispire Admin 6633d0cca3 forward chat msg fix 2026-09-02 23:00:55 +05:30
Krispire Admin 0fee56d272 feat: vani of the day edit 2026-08-31 13:39:56 +05:30
Krispire Admin 44fd1d69ab fix: hide suspended account 2026-08-31 12:28:25 +05:30
Krispire Admin ac92bc2cf3 feat: chat notification 2026-08-30 11:13:33 +05:30
Krispire Admin a2d22ed829 feat: add stickers delete api 2026-08-30 10:31:37 +05:30
Krispire Admin 4427849647 feat: custom stickers apis 2026-08-30 03:02:49 +05:30
Krispire Admin fecb9bc045 feat: add stickers 2026-08-30 02:18:53 +05:30
Krispire Admin cee2d3c874 feat: chat reactions 2026-08-30 01:51:32 +05:30
Krispire Admin 9c458ba19f fix: no attachment broadcast fix 2026-08-29 18:56:33 +05:30
Krispire Admin f65f552c87 feat: broadcast 2026-08-29 18:34:07 +05:30
Krispire Admin 44120efdec fix: chat UI 2026-08-29 17:30:14 +05:30
Krispire Admin 7a730cd42e fix: chat accepts posts 2026-08-29 16:26:38 +05:30
Krispire Admin 18bac9a22d fix: chat accepts quotes now 2026-08-29 11:58:20 +05:30
Krispire Admin d50031cdb3 feat: add chat 2026-08-29 03:49:30 +05:30
Krispire Admin 3b10e726b7 feat: add private r2 bucket 2026-08-29 01:11:22 +05:30
Krispire Admin 623935df22 feat: profile bio 2026-08-07 23:47:03 +05:30
Krispire Admin afcdd7ef19 feat: disciple link 2026-08-07 22:46:08 +05:30
Krispire Admin 4553725d4e feat: auth user feedbacks 2026-07-14 18:26:03 +05:30
Krispire Admin 47b3a6d96e fix: daily vani 2026-07-11 01:13:34 +05:30
Krispire Admin 3de7e5faec feat: url dashboard 2026-07-11 01:10:35 +05:30
Krispire Admin fa3d2b932c feat: daily vani 2026-07-11 00:57:27 +05:30
Krispire Admin d29eb5d844 feat: daily vani 2026-07-11 00:44:08 +05:30
Krispire Admin c26904b61e feat: daily vani 2026-07-11 00:31:22 +05:30
Krispire Admin 9c9c46caf1 feat: audio track history 2026-07-02 19:22:54 +05:30
Krispire Admin 44a0016513 fix: bg purport reseed 2026-06-23 15:01:03 +05:30
Krispire Admin 96ebbfd719 feat: enhance limits 2026-06-23 14:21:45 +05:30
Krispire Admin eadef33eac fix: search fix 2026-06-23 14:04:08 +05:30
Krispire Admin fe0bee4ce7 fix: api rate limit 2026-06-21 21:20:30 +05:30
Krispire Admin d52530c52c fix: user location fix 2026-06-18 23:52:17 +05:30
Krispire Admin dc204577a8 fix: user location fix 2026-06-18 22:57:12 +05:30
Krispire Admin 5c0581a1da feat: friend request notifications 2026-06-18 22:45:16 +05:30
Krispire Admin 9235f68402 fix: feed card follow ui 2026-06-18 00:20:52 +05:30
Krispire Admin be4b1636c0 feat: add book categorisation in transcripts 2026-06-17 12:40:19 +05:30
Krispire Admin a63ef672bb fix: fix ui reports 2026-06-16 19:03:06 +05:30
Krispire Admin 1cde0c0544 chore: update admin apis 2026-06-16 16:25:44 +05:30
Krispire Admin 04ad94ad3c chore: update admin apis 2026-06-16 15:48:44 +05:30
Krispire Admin 4c42d9a7f9 chore: update admin apis 2026-06-16 15:45:33 +05:30
Krispire Admin a49bf7591e feat: add admin apis 2026-06-16 14:47:45 +05:30
Krispire Admin aa61f4f0b1 feat(seo): add sitemap generator 2026-06-15 13:53:18 +05:30
Krispire Admin bbe491ae13 feat(seo): add sitemap generator 2026-06-15 13:28:15 +05:30
Krispire Admin 438258ade0 fix: hide post 2026-06-14 19:51:57 +05:30
Krispire Admin 93e92c610c feat: add sb front matter 2026-06-14 15:32:29 +05:30
Krispire Admin 4946ce1062 feat: add sb front matter 2026-06-14 14:49:14 +05:30
Krispire Admin 025c83eec5 feat: add cc front matter 2026-06-14 13:57:54 +05:30
Krispire Admin c67a657ca2 feat: add bg front matter 2026-06-14 13:28:16 +05:30
Krispire Admin 2afbbfa0f2 fix: feed repitition 2026-06-14 00:18:11 +05:30
Krispire Admin 8a2bdf945a feat: add feed 2026-06-13 23:55:02 +05:30
Krispire Admin 2135ec334c feat: add inline mentions in comments 2026-06-13 13:10:05 +05:30
Krispire Admin 05567ad438 feat: add comments count 2026-06-13 02:24:21 +05:30
Krispire Admin 6ebaa36158 feat: add comments 2026-06-13 01:29:55 +05:30
Krispire Admin 1edee92865 fix: hide blocked user content 2026-06-13 00:46:51 +05:30
Krispire Admin 0b24ab4b45 fix: migration 2026-06-11 01:11:10 +05:30
Krispire Admin 0655b2314d feat: notification enhancement 2026-06-11 01:00:31 +05:30
Krispire Admin 187bcd1442 feat: notification enhancement 2026-06-11 00:31:35 +05:30
Krispire Admin cb62adf2de feat: add post mention 2026-06-10 16:28:57 +05:30
Krispire Admin d86d876ac1 feat: add email notification for post like 2026-06-09 16:57:12 +05:30
Krispire Admin e9299051f5 feat: add post like 2026-06-09 16:28:40 +05:30
Krispire Admin da2bbc323d feat: add folloers/following list 2026-06-09 13:20:14 +05:30
Krispire Admin 0188a34cf6 chore: add logs on sadhna API 2026-06-09 12:10:45 +05:30
Krispire Admin 0d189739d6 fix: sync timezone fix 2026-06-09 11:56:22 +05:30
Krispire Admin 6c61e97bba fix: sync sadhna calendar with local timezone 2026-06-09 00:39:03 +05:30
Krispire Admin 1f02ae26f6 fix: push notification fix 2026-06-08 18:42:31 +05:30
Krispire Admin 29c5f7cee5 fix: logs fix 2026-06-08 18:36:47 +05:30
Krispire Admin e79f347197 fix: notification title fix 2026-06-08 18:33:33 +05:30
Krispire Admin 26f0e298b5 chore: apn jwt 2026-06-08 18:22:11 +05:30
Krispire Admin f2a04ae652 chore: add logger for push notifications 2026-06-08 18:13:24 +05:30
Krispire Admin 05d906aee9 fix: push notification 2026-06-08 17:52:21 +05:30
Krispire Admin 0c8b2a54b3 fix: push notification 2026-06-08 17:18:02 +05:30
Krispire Admin 3902033508 feat: add eve chanting notif 2026-06-08 16:28:15 +05:30
Krispire Admin 39450c3266 fix: reward day calculation 2026-06-08 16:09:25 +05:30
Krispire Admin f598770b32 feat: add web push and sadhna tracker 2026-06-08 15:47:44 +05:30
Krispire Admin e352948fb1 feat: add websocket 2026-06-07 00:23:21 +05:30
203 changed files with 27824 additions and 305 deletions
+21 -1
View File
@@ -24,6 +24,11 @@ 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 + /feedback admin notifications)
SMTP_HOST=
@@ -53,8 +58,23 @@ COOKIE_SECURE=false
# Frontend (CORS + email link base)
FRONTEND_URL=http://localhost:3000
# Admin panel origin (CORS). Defaults to http://localhost:3001 when APP_ENV=development.
ADMIN_URL=
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
FACEBOOK_OAUTH_CLIENT_ID=
FACEBOOK_OAUTH_CLIENT_SECRET=
FACEBOOK_OAUTH_CLIENT_SECRET=
# SOKETI
SOKETI_APP_ID=
SOKETI_APP_KEY=
SOKETI_APP_SECRET=
SOKETI_HOST=ws.prabhupadasays.org
SOKETI_PORT=443
SOKETI_USE_TLS=true
# Web Push
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=
+33 -7
View File
@@ -12,8 +12,39 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/config"
"github.com/prabhupadasays/spsays-bff/internal/server"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// newLogger builds the app logger, routing logs by level: Debug/Info/Warn go
// to stdout and only Error/Fatal/Panic go to stderr. Hosting platforms tag
// every stderr line as "error", so without this split a routine Debug/Info
// line shows up with a misleading error badge. Production uses JSON at Info+;
// development uses colored console at Debug+.
func newLogger(production bool) *zap.Logger {
var encoder zapcore.Encoder
minLevel := zapcore.DebugLevel
if production {
encoder = zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig())
minLevel = zapcore.InfoLevel
} else {
encCfg := zap.NewDevelopmentEncoderConfig()
encCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder
encoder = zapcore.NewConsoleEncoder(encCfg)
}
low := zap.LevelEnablerFunc(func(l zapcore.Level) bool {
return l >= minLevel && l < zapcore.ErrorLevel
})
high := zap.LevelEnablerFunc(func(l zapcore.Level) bool {
return l >= zapcore.ErrorLevel
})
core := zapcore.NewTee(
zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), low),
zapcore.NewCore(encoder, zapcore.Lock(os.Stderr), high),
)
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
}
func main() {
// Load config
cfg, err := config.Load()
@@ -22,13 +53,8 @@ func main() {
os.Exit(1)
}
// Initialize logger
var logger *zap.Logger
if cfg.Env == "production" {
logger, _ = zap.NewProduction()
} else {
logger, _ = zap.NewDevelopment()
}
// Initialize logger — routes normal logs to stdout, errors to stderr.
logger := newLogger(cfg.Env == "production")
defer logger.Sync()
sugar := logger.Sugar()
+65 -17
View File
@@ -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 ====================
+153
View File
@@ -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)
}
+152
View File
@@ -0,0 +1,152 @@
// Command uploadrewards is a one-off admin tool that uploads the sādhana
// reward images to Cloudflare R2 and writes the resulting public URLs back
// into rewards.json (and the embedded copy the BFF seeds from).
//
// Usage (from the spsays-bff directory, with R2_* in .env):
//
// go run ./cmd/uploadrewards
// go run ./cmd/uploadrewards -json ../sadhna-rewards/rewards.json
//
// It reads each reward's `file` from the images directory, uploads it to
// `<r2_prefix>/<file>` in the configured bucket, and sets `image_url` to the
// public URL. Re-running it is safe (objects are overwritten in place).
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/joho/godotenv"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
type rewardEntry struct {
Key string `json:"key"`
File string `json:"file"`
Name string `json:"name"`
Meaning string `json:"meaning"`
Active bool `json:"active"`
ImageURL string `json:"image_url"`
}
type catalog struct {
Note string `json:"_note,omitempty"`
R2Prefix string `json:"r2_prefix"`
Rewards []rewardEntry `json:"rewards"`
}
func main() {
jsonPath := flag.String("json", "../sadhna-rewards/rewards.json", "path to rewards.json")
embedPath := flag.String("embed", "internal/repository/sadhna_rewards.json", "path to the BFF embed copy to keep in sync")
envPath := flag.String("env", ".env", "path to .env with R2_* vars")
dir := flag.String("dir", "", "images directory (default: directory of -json)")
flag.Parse()
_ = godotenv.Load(*envPath)
r2cfg := repository.R2Config{
AccountID: os.Getenv("R2_ACCOUNT_ID"),
AccessKeyID: os.Getenv("R2_ACCESS_KEY_ID"),
SecretAccessKey: os.Getenv("R2_SECRET_ACCESS_KEY"),
Bucket: os.Getenv("R2_BUCKET"),
PublicURL: os.Getenv("R2_PUBLIC_URL"),
}
r2, err := repository.NewR2(r2cfg)
if err != nil {
log.Fatalf("R2 init: %v", err)
}
if r2 == nil {
log.Fatalf("R2 not configured — set R2_* in %s", *envPath)
}
raw, err := os.ReadFile(*jsonPath)
if err != nil {
log.Fatalf("read %s: %v", *jsonPath, err)
}
var cat catalog
if err := json.Unmarshal(raw, &cat); err != nil {
log.Fatalf("parse %s: %v", *jsonPath, err)
}
imagesDir := *dir
if imagesDir == "" {
imagesDir = filepath.Dir(*jsonPath)
}
prefix := strings.Trim(cat.R2Prefix, "/")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
for i := range cat.Rewards {
rw := &cat.Rewards[i]
if rw.File == "" {
log.Printf("skip %q: no file", rw.Key)
continue
}
body, err := os.ReadFile(filepath.Join(imagesDir, rw.File))
if err != nil {
log.Fatalf("read image %s: %v", rw.File, err)
}
key := rw.File
if prefix != "" {
key = prefix + "/" + rw.File
}
url, err := r2.PutObject(ctx, key, contentType(rw.File), body)
if err != nil {
log.Fatalf("upload %s: %v", rw.File, err)
}
rw.ImageURL = url
fmt.Printf("✓ %-32s → %s\n", rw.Key, url)
}
out, err := marshalPretty(cat)
if err != nil {
log.Fatalf("encode json: %v", err)
}
if err := os.WriteFile(*jsonPath, out, 0o644); err != nil {
log.Fatalf("write %s: %v", *jsonPath, err)
}
if *embedPath != "" {
if err := os.WriteFile(*embedPath, out, 0o644); err != nil {
log.Fatalf("write embed copy %s: %v", *embedPath, err)
}
fmt.Printf("\nSynced embed copy: %s\n", *embedPath)
}
fmt.Printf("Uploaded %d reward image(s). image_url written to %s\n", len(cat.Rewards), *jsonPath)
}
func contentType(file string) string {
switch strings.ToLower(filepath.Ext(file)) {
case ".png":
return "image/png"
case ".svg":
return "image/svg+xml"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".webp":
return "image/webp"
case ".gif":
return "image/gif"
default:
return "application/octet-stream"
}
}
func marshalPretty(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false) // keep diacritics / punctuation literal
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+1
View File
@@ -3,6 +3,7 @@ module github.com/prabhupadasays/spsays-bff
go 1.25.0
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/aws/aws-sdk-go-v2 v1.41.9
github.com/aws/aws-sdk-go-v2/credentials v1.19.19
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2
+43
View File
@@ -1,6 +1,8 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
@@ -41,6 +43,7 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
@@ -107,16 +110,34 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -124,19 +145,41 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+51 -1
View File
@@ -37,6 +37,13 @@ type Config struct {
R2Bucket string
R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL
// 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
@@ -66,6 +73,15 @@ 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
@@ -77,6 +93,26 @@ type Config struct {
GoogleOAuthClientSecret string
FacebookOAuthClientID string
FacebookOAuthClientSecret string
// Soketi (Pusher-compatible WebSocket server) — used to push realtime
// notifications. Empty App credentials disable realtime delivery (the
// DB row + email still happen). SoketiHost is the HTTP-API origin the
// BFF triggers events against (e.g. "ws.prabhupadasays.org"); the
// frontend connects to the same host over WSS.
SoketiAppID string
SoketiAppKey string
SoketiAppSecret string
SoketiHost string
SoketiPort int
SoketiUseTLS bool
// Web Push (VAPID). Empty keys disable push delivery (other channels
// still run). VAPIDSubject is a mailto: or https: contact URL required
// by the spec. The public key is also served to the browser to
// subscribe.
VAPIDPublicKey string
VAPIDPrivateKey string
VAPIDSubject string
}
// Load reads configuration from environment variables (with .env fallback).
@@ -107,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),
@@ -127,7 +164,9 @@ 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"),
@@ -135,6 +174,17 @@ func Load() (*Config, error) {
GoogleOAuthClientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET", ""),
FacebookOAuthClientID: getEnv("FACEBOOK_OAUTH_CLIENT_ID", ""),
FacebookOAuthClientSecret: getEnv("FACEBOOK_OAUTH_CLIENT_SECRET", ""),
SoketiAppID: getEnv("SOKETI_APP_ID", ""),
SoketiAppKey: getEnv("SOKETI_APP_KEY", ""),
SoketiAppSecret: getEnv("SOKETI_APP_SECRET", ""),
SoketiHost: getEnv("SOKETI_HOST", "ws.prabhupadasays.org"),
SoketiPort: getEnvInt("SOKETI_PORT", 443),
SoketiUseTLS: getEnvBool("SOKETI_USE_TLS", true),
VAPIDPublicKey: getEnv("VAPID_PUBLIC_KEY", ""),
VAPIDPrivateKey: getEnv("VAPID_PRIVATE_KEY", ""),
VAPIDSubject: getEnv("VAPID_SUBJECT", "mailto:admin@prabhupadasays.org"),
}
if cfg.MongoURI == "" {
+448
View File
@@ -0,0 +1,448 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"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
// staff guards actions that would otherwise reach other staff members.
staff *repository.StaffRepo
}
func NewAdminHandler(svc *service.AdminService, staff *repository.StaffRepo) *AdminHandler {
return &AdminHandler{svc: svc, staff: staff}
}
// 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) {
target := chi.URLParam(r, "id")
// A banned account loses panel access, so banning a staff member is a way
// to remove them — which only superadmins may do. Without this, an Admin
// holding users:ban could ban a Superadmin out of the panel.
if _, err := h.staff.Access(r.Context(), target); err == nil {
if s := middleware.Staff(r.Context()); s == nil || !s.Perms.Has(rbac.ManageStaff) {
response.Error(w, http.StatusForbidden,
"staff accounts are managed from the Staff page by a superadmin")
return
}
if target == middleware.UserID(r.Context()) {
response.Error(w, http.StatusConflict, "you can't ban yourself")
return
}
}
ok, err := h.svc.SetUserBanned(r.Context(), target, 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})
}
+50
View File
@@ -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
}
+98
View File
@@ -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)
}
+2
View File
@@ -375,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")
}
+151
View File
@@ -0,0 +1,151 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"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"
)
// BroadcastHandler serves both sides of announcements: the admin routes that
// compose and publish them, and the two app routes that fetch and dismiss.
type BroadcastHandler struct {
svc *service.BroadcastService
}
func NewBroadcastHandler(svc *service.BroadcastService) *BroadcastHandler {
return &BroadcastHandler{svc: svc}
}
func writeBroadcastError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrBroadcastGone):
response.Error(w, http.StatusNotFound, "broadcast not found")
case errors.Is(err, service.ErrBroadcastTitle),
errors.Is(err, service.ErrBroadcastBody),
errors.Is(err, service.ErrBroadcastCTA),
errors.Is(err, service.ErrBroadcastBanner):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "broadcast request failed")
}
}
// ── App routes ────────────────────────────────────────────────────────────
// Pending — GET /broadcasts/pending. Returns null when nothing is waiting, so
// the client can call it on load and after a nudge without special-casing.
func (h *BroadcastHandler) Pending(w http.ResponseWriter, r *http.Request) {
b, err := h.svc.Pending(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeBroadcastError(w, err)
return
}
response.JSON(w, http.StatusOK, b)
}
// Seen — POST /broadcasts/{id}/seen. The "at most once" half; idempotent.
func (h *BroadcastHandler) Seen(w http.ResponseWriter, r *http.Request) {
if err := h.svc.MarkSeen(r.Context(), chi.URLParam(r, "id"),
middleware.UserID(r.Context())); err != nil {
writeBroadcastError(w, err)
return
}
response.NoContent(w)
}
// ── Admin routes ──────────────────────────────────────────────────────────
type createBroadcastReq struct {
Title string `json:"title"`
Body string `json:"body"`
BannerKey *string `json:"banner_key"`
CTALabel *string `json:"cta_label"`
CTAURL *string `json:"cta_url"`
// ExpiresAt stops the announcement being offered. Without one, someone
// signing up months later would still be shown it.
ExpiresAt *time.Time `json:"expires_at"`
// Publish immediately rather than saving a draft.
Publish bool `json:"publish"`
}
// Create — POST /admin/broadcasts
func (h *BroadcastHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createBroadcastReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
b, err := h.svc.Create(r.Context(), middleware.UserID(r.Context()), service.CreateParams{
Title: req.Title, Body: req.Body, BannerKey: req.BannerKey,
CTALabel: req.CTALabel, CTAURL: req.CTAURL, ExpiresAt: req.ExpiresAt,
})
if err != nil {
writeBroadcastError(w, err)
return
}
if req.Publish {
published, err := h.svc.Publish(r.Context(), b.ID)
if err != nil {
writeBroadcastError(w, err)
return
}
b = published
}
response.JSON(w, http.StatusCreated, b)
}
// Publish — POST /admin/broadcasts/{id}/publish
func (h *BroadcastHandler) Publish(w http.ResponseWriter, r *http.Request) {
b, err := h.svc.Publish(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeBroadcastError(w, err)
return
}
response.JSON(w, http.StatusOK, b)
}
// List — GET /admin/broadcasts
func (h *BroadcastHandler) List(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := h.svc.List(r.Context(), limit)
if err != nil {
writeBroadcastError(w, err)
return
}
response.JSON(w, http.StatusOK, items)
}
// Delete — DELETE /admin/broadcasts/{id}
func (h *BroadcastHandler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id")); err != nil {
writeBroadcastError(w, err)
return
}
response.NoContent(w)
}
// PresignBanner — POST /admin/broadcasts/banner-presign
func (h *BroadcastHandler) PresignBanner(w http.ResponseWriter, r *http.Request) {
var req struct {
ContentType string `json:"content_type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, key, err := h.svc.PresignBanner(r.Context(), strings.TrimSpace(req.ContentType))
if err != nil {
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key})
}
+190
View File
@@ -0,0 +1,190 @@
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
},
}
}
// ConversationRule allows a caller to subscribe to a thread's channel only
// while they are a participant. isParticipant is injected rather than taking a
// repository dependency here, so the authorization check stays the service's
// single source of truth.
func ConversationRule(isParticipant func(ctx context.Context, userID, conversationID string) (bool, error)) ChannelRule {
return ChannelRule{
Prefix: "private-conversation.",
Authorize: func(ctx context.Context, userID, suffix string) (bool, error) {
if suffix == "" {
return false, nil
}
return isParticipant(ctx, userID, suffix)
},
}
}
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"))
}
+191
View File
@@ -0,0 +1,191 @@
package handler
import (
"context"
"encoding/json"
"errors"
"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.
// ConversationRule delegates to the chat service's membership check, so the
// realtime channel and the REST endpoints can never disagree about who is in a
// thread.
func TestConversationRule(t *testing.T) {
const member, outsider = "member-1", "outsider-1"
rule := ConversationRule(func(_ context.Context, userID, conversationID string) (bool, error) {
if conversationID == "boom" {
return false, errors.New("database down")
}
return userID == member && conversationID == "conv-1", nil
})
if rule.Prefix != "private-conversation." {
t.Errorf("prefix = %q", rule.Prefix)
}
tests := []struct {
name string
user string
suffix string
wantOK bool
wantErr bool
}{
{"participant", member, "conv-1", true, false},
{"outsider", outsider, "conv-1", false, false},
{"participant of another thread", member, "conv-2", false, false},
{"empty conversation id", member, "", false, false},
{"lookup failure is not a denial", member, "boom", false, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ok, err := rule.Authorize(context.Background(), tc.user, tc.suffix)
if ok != tc.wantOK || (err != nil) != tc.wantErr {
t.Errorf("= (%v, %v), want (%v, err=%v)", ok, err, tc.wantOK, tc.wantErr)
}
})
}
}
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)
}
}
+781
View File
@@ -0,0 +1,781 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"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"
)
// ChatHandler exposes the chat inbox, threads and messages.
//
// Every route is session-authenticated and every read re-checks membership in
// the service, so a conversation id is not a capability: knowing one gets you
// nothing if you were never in the thread (or have left it).
type ChatHandler struct {
svc *service.ChatService
}
func NewChatHandler(svc *service.ChatService) *ChatHandler { return &ChatHandler{svc: svc} }
// writeChatError maps service errors onto status codes. Not-a-participant is a
// 404, not a 403: confirming a thread exists to someone outside it leaks that
// two people are talking.
func writeChatError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrNotParticipant), errors.Is(err, repository.ErrConversationGone):
response.Error(w, http.StatusNotFound, "conversation not found")
case errors.Is(err, service.ErrAvatarUnset):
response.Error(w, http.StatusNotFound, err.Error())
case errors.Is(err, service.ErrChatBadEntity):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, repository.ErrStickerNotOwned):
response.Error(w, http.StatusForbidden, "that sticker isn't in your collection")
case errors.Is(err, service.ErrChatBadEmoji):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatBadQuote):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatBadAvatarKey):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, repository.ErrMessageNotHere):
response.Error(w, http.StatusNotFound, "message not found")
case errors.Is(err, repository.ErrAttachmentGone):
response.Error(w, http.StatusNotFound, "attachment not found")
case errors.Is(err, service.ErrChatMediaOff):
response.Error(w, http.StatusServiceUnavailable, err.Error())
case errors.Is(err, service.ErrChatBadMedia):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatNotAdmin), errors.Is(err, service.ErrChatOwnerImmutable),
errors.Is(err, service.ErrChatAdminsOnly):
response.Error(w, http.StatusForbidden, err.Error())
case errors.Is(err, service.ErrChatNotGroup), errors.Is(err, service.ErrChatBadRole),
errors.Is(err, service.ErrChatBadPolicy):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatNotSender):
response.Error(w, http.StatusForbidden, err.Error())
case errors.Is(err, service.ErrMessageDeleted), errors.Is(err, service.ErrChatBadReport):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatNotAccepting):
response.Error(w, http.StatusForbidden, err.Error())
case errors.Is(err, service.ErrChatBadWhoCanMessage), errors.Is(err, service.ErrChatBadAccountAge),
errors.Is(err, service.ErrChatGenderUnset):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrChatBlocked):
response.Error(w, http.StatusForbidden, err.Error())
case errors.Is(err, service.ErrChatReadOnly):
response.Error(w, http.StatusConflict, "Kautilya is not available yet")
case errors.Is(err, service.ErrChatEmptyBody), errors.Is(err, service.ErrChatBodyTooLong),
errors.Is(err, service.ErrChatBadMuteDuration),
errors.Is(err, repository.ErrReplyTargetNotHere):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "chat request failed")
}
}
// Inbox — GET /chat/conversations?tab=chats|kautilya
func (h *ChatHandler) Inbox(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
tab := r.URL.Query().Get("tab")
items, err := h.svc.Inbox(r.Context(), userID, tab, atoiOr(r.URL.Query().Get("limit"), 0))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, items)
}
type openDMReq struct {
// Handle, not id: profiles are addressed by handle everywhere else, and
// the public profile payload carries no user id.
Handle string `json:"handle"`
}
// Unread — GET /chat/unread. Drives the nav badge, so it stays a single
// count rather than anything the caller has to aggregate.
func (h *ChatHandler) Unread(w http.ResponseWriter, r *http.Request) {
n, err := h.svc.UnreadCount(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]int{"count": n})
}
// OpenDM — POST /chat/conversations/dm. Idempotent: returns the existing
// thread when there is one, so the client can call it every time it opens a
// profile without checking first.
func (h *ChatHandler) OpenDM(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
var req openDMReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Handle == "" {
response.Error(w, http.StatusBadRequest, "handle is required")
return
}
conv, err := h.svc.OpenDMByHandle(r.Context(), userID, req.Handle)
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, conv)
}
type createGroupReq struct {
Title string `json:"title"`
MemberHandles []string `json:"member_handles"`
}
// CreateGroup — POST /chat/conversations/group
func (h *ChatHandler) CreateGroup(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
var req createGroupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
conv, err := h.svc.CreateGroup(r.Context(), userID, req.Title, req.MemberHandles)
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusCreated, conv)
}
// OpenAIThread — POST /chat/conversations/kautilya
func (h *ChatHandler) OpenAIThread(w http.ResponseWriter, r *http.Request) {
conv, err := h.svc.OpenAIThread(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, conv)
}
// Detail — GET /chat/conversations/{id}
func (h *ChatHandler) Detail(w http.ResponseWriter, r *http.Request) {
detail, err := h.svc.Detail(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, detail)
}
// Messages — GET /chat/conversations/{id}/messages?before_seq=&limit=
//
// before_seq is an exclusive upper bound rather than an offset, so paging stays
// stable while new messages keep arriving at the top.
func (h *ChatHandler) Messages(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
convID := chi.URLParam(r, "id")
msgs, err := h.svc.Messages(r.Context(), userID, convID,
int64(atoiOr(r.URL.Query().Get("before_seq"), 0)),
atoiOr(r.URL.Query().Get("limit"), 0))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, msgs)
}
type mediaReq struct {
Key string `json:"key"`
Mime string `json:"mime"`
SizeBytes *int64 `json:"size_bytes"`
Width *int `json:"width"`
Height *int `json:"height"`
DurationMs *int `json:"duration_ms"`
Blurhash *string `json:"blurhash"`
FileName *string `json:"file_name"`
}
type sendReq struct {
Body string `json:"body"`
ClientMsgID *string `json:"client_msg_id"`
// preview_url is the link the sender chose to keep a card for; omitted
// when they dismissed it. Only the URL — never the card's contents.
PreviewURL *string `json:"preview_url"`
Attachments []mediaReq `json:"attachments"`
// quote is a passage shared from the library. `path` must be app-relative;
// the service refuses anything that could navigate off-site.
Quote *quoteReq `json:"quote"`
// sticker_key sends reward artwork. Ownership is checked server-side.
StickerKey *string `json:"sticker_key"`
// entity shares something else in the app — a post — as a card. Only the
// reference; the BFF resolves the contents and re-checks visibility.
Entity *entityReq `json:"entity"`
ReplyToID *string `json:"reply_to_id"`
// forward_from_id is deliberately NOT accepted yet: forwarding lands with
// its own "can the caller see the source?" check in a later phase, and
// accepting an unvalidated message reference in the meantime would let a
// client point at any message in the system.
}
// Send — POST /chat/conversations/{id}/messages
func (h *ChatHandler) Send(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
convID := chi.URLParam(r, "id")
var req sendReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
media := make([]service.MediaAttachment, 0, len(req.Attachments))
for _, a := range req.Attachments {
media = append(media, service.MediaAttachment{
Key: a.Key, Mime: a.Mime, SizeBytes: a.SizeBytes, Width: a.Width,
Height: a.Height, DurationMs: a.DurationMs, Blurhash: a.Blurhash,
FileName: a.FileName,
})
}
var quote *service.QuoteAttachment
if req.Quote != nil {
quote = &service.QuoteAttachment{
Path: req.Quote.Path, Title: req.Quote.Title, Text: req.Quote.Text,
}
}
var entity *service.EntityAttachment
if req.Entity != nil {
entity = &service.EntityAttachment{Type: req.Entity.Type, ID: req.Entity.ID}
}
msg, err := h.svc.Send(r.Context(), userID, convID, service.SendRequest{
Body: req.Body,
ClientMsgID: req.ClientMsgID,
ReplyToID: req.ReplyToID,
PreviewURL: req.PreviewURL,
Quote: quote,
StickerKey: req.StickerKey,
Entity: entity,
Attachments: media,
})
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusCreated, msg)
}
// Settings — GET /chat/settings
func (h *ChatHandler) Settings(w http.ResponseWriter, r *http.Request) {
settings, err := h.svc.Settings(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, settings)
}
type chatSettingsReq struct {
ReadReceipts *bool `json:"read_receipts"`
WhoCanMessage *string `json:"who_can_message"`
MinAccountAgeMonths *int `json:"min_account_age_months"`
TrainKautilya *bool `json:"train_kautilya"`
}
// UpdateSettings — PATCH /chat/settings
//
// Fields are optional: anything omitted keeps its current value, so a toggle
// doesn't have to send the whole object back.
func (h *ChatHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserID(r.Context())
current, err := h.svc.Settings(r.Context(), userID)
if err != nil {
writeChatError(w, err)
return
}
var req chatSettingsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.ReadReceipts != nil {
current.ReadReceipts = *req.ReadReceipts
}
if req.WhoCanMessage != nil {
current.WhoCanMessage = *req.WhoCanMessage
}
if req.MinAccountAgeMonths != nil {
current.MinAccountAgeMonths = *req.MinAccountAgeMonths
}
if req.TrainKautilya != nil {
current.TrainKautilya = *req.TrainKautilya
}
saved, err := h.svc.SaveSettings(r.Context(), userID, current)
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, saved)
}
type linkPreviewReq struct {
URL string `json:"url"`
}
// LinkPreview — POST /chat/link-preview
//
// The composer calls this as a link is typed so the sender can see the card
// and dismiss it before sending. Only the URL travels in either direction's
// trust boundary: the BFF fetches the metadata itself.
func (h *ChatHandler) LinkPreview(w http.ResponseWriter, r *http.Request) {
var req linkPreviewReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.URL) == "" {
response.Error(w, http.StatusBadRequest, "url is required")
return
}
preview, err := h.svc.LinkPreview(r.Context(), req.URL)
if err != nil {
// No card is a normal outcome, not a server fault.
response.Error(w, http.StatusUnprocessableEntity, "no preview available")
return
}
response.JSON(w, http.StatusOK, preview)
}
type chatPresignReq struct {
ContentType string `json:"content_type"`
}
// PresignAttachment — POST /chat/conversations/{id}/attachments/presign
func (h *ChatHandler) PresignAttachment(w http.ResponseWriter, r *http.Request) {
var req chatPresignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, key, err := h.svc.PresignAttachment(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "id"), strings.TrimSpace(req.ContentType))
if err != nil {
writeChatError(w, err)
return
}
// The key comes back so the client can name it when it sends the message;
// there is no public URL, by design.
response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key})
}
// Attachment — GET /chat/attachments/{attachmentID}
//
// A stable URL that redirects to a short-lived signed one. Membership is
// re-checked on every fetch, so access revoked after a link was shared stops
// working — which a signed URL baked into the message payload could not do.
func (h *ChatHandler) Attachment(w http.ResponseWriter, r *http.Request) {
url, err := h.svc.AttachmentURL(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "attachmentID"))
if err != nil {
writeChatError(w, err)
return
}
// Private and short-lived: never let a shared cache hold onto it.
w.Header().Set("Cache-Control", "private, no-store")
http.Redirect(w, r, url, http.StatusFound)
}
// PresignGroupAvatar — POST /chat/conversations/{id}/avatar/presign
func (h *ChatHandler) PresignGroupAvatar(w http.ResponseWriter, r *http.Request) {
var req chatPresignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, key, err := h.svc.PresignGroupAvatar(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "id"), strings.TrimSpace(req.ContentType))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]string{"upload_url": upload, "key": key})
}
// GroupAvatar — GET /chat/conversations/{id}/avatar
//
// Stable URL that redirects to a short-lived signed one, re-checking
// membership each time, exactly as message attachments do.
func (h *ChatHandler) GroupAvatar(w http.ResponseWriter, r *http.Request) {
url, err := h.svc.GroupAvatarURL(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeChatError(w, err)
return
}
w.Header().Set("Cache-Control", "private, no-store")
http.Redirect(w, r, url, http.StatusFound)
}
// Gallery — GET /chat/conversations/{id}/gallery?tab=media|links&before=
func (h *ChatHandler) Gallery(w http.ResponseWriter, r *http.Request) {
var before *time.Time
if raw := r.URL.Query().Get("before"); raw != "" {
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
before = &t
}
}
items, err := h.svc.Gallery(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), r.URL.Query().Get("tab"), before,
atoiOr(r.URL.Query().Get("limit"), 0))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, items)
}
// Starred — GET /chat/conversations/{id}/starred
func (h *ChatHandler) Starred(w http.ResponseWriter, r *http.Request) {
msgs, err := h.svc.Starred(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), atoiOr(r.URL.Query().Get("limit"), 0))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, msgs)
}
// Star / Unstar — POST|DELETE /chat/conversations/{id}/messages/{messageID}/star
func (h *ChatHandler) Star(w http.ResponseWriter, r *http.Request) {
h.setStar(w, r, true)
}
func (h *ChatHandler) Unstar(w http.ResponseWriter, r *http.Request) {
h.setStar(w, r, false)
}
func (h *ChatHandler) setStar(w http.ResponseWriter, r *http.Request, starred bool) {
err := h.svc.SetStar(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), chi.URLParam(r, "messageID"), starred)
if err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// DeleteMessage — DELETE /chat/conversations/{id}/messages/{messageID}?scope=me|everyone
func (h *ChatHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) {
scope := r.URL.Query().Get("scope")
if scope == "" {
scope = service.DeleteForMe // the safer default
}
err := h.svc.DeleteMessage(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID"), scope)
if err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type forwardReq struct {
MessageID string `json:"message_id"`
}
// Forward — POST /chat/conversations/{id}/forward
//
// The path names the DESTINATION; the body names the message being copied in.
func (h *ChatHandler) Forward(w http.ResponseWriter, r *http.Request) {
var req forwardReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.MessageID == "" {
response.Error(w, http.StatusBadRequest, "message_id is required")
return
}
msg, err := h.svc.ForwardMessage(r.Context(), middleware.UserID(r.Context()),
req.MessageID, chi.URLParam(r, "id"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusCreated, msg)
}
type chatReportReq struct {
Category string `json:"category"`
Reason string `json:"reason"`
}
// ReportMessage — POST /chat/conversations/{id}/messages/{messageID}/report
func (h *ChatHandler) ReportMessage(w http.ResponseWriter, r *http.Request) {
var req chatReportReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
err := h.svc.ReportMessage(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID"), req.Category, req.Reason)
if err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type membersReq struct {
// Handles, not ids: search results and follow lists identify people by
// handle, and it matches /u/{handle}/block elsewhere in the API.
MemberHandles []string `json:"member_handles"`
}
// AddMembers — POST /chat/conversations/{id}/members
func (h *ChatHandler) AddMembers(w http.ResponseWriter, r *http.Request) {
var req membersReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.AddMembers(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), req.MemberHandles); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// RemoveMember — DELETE /chat/conversations/{id}/members/{handle}
//
// Passing your own handle is how you leave.
func (h *ChatHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
if err := h.svc.RemoveMember(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), chi.URLParam(r, "handle")); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type roleReq struct {
Role string `json:"role"`
}
// SetMemberRole — PATCH /chat/conversations/{id}/members/{userID}
func (h *ChatHandler) SetMemberRole(w http.ResponseWriter, r *http.Request) {
var req roleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetMemberRole(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), chi.URLParam(r, "handle"), req.Role); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type updateGroupReq struct {
Title *string `json:"title"`
SendPolicy *string `json:"send_policy"`
// AvatarKey comes from PresignGroupAvatar; the service checks it belongs
// to this group before storing it.
AvatarKey *string `json:"avatar_key"`
}
// UpdateGroup — PATCH /chat/conversations/{id}
func (h *ChatHandler) UpdateGroup(w http.ResponseWriter, r *http.Request) {
var req updateGroupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.UpdateGroup(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), req.Title, req.SendPolicy, req.AvatarKey); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type entityReq struct {
Type string `json:"type"`
ID string `json:"id"`
}
type quoteReq struct {
Path string `json:"path"`
Title string `json:"title"`
Text string `json:"text"`
}
type receiptReq struct {
Seq int64 `json:"seq"`
}
// MarkRead — POST /chat/conversations/{id}/read
func (h *ChatHandler) MarkRead(w http.ResponseWriter, r *http.Request) {
h.receipt(w, r, h.svc.MarkRead)
}
// MarkDelivered — POST /chat/conversations/{id}/delivered
func (h *ChatHandler) MarkDelivered(w http.ResponseWriter, r *http.Request) {
h.receipt(w, r, h.svc.MarkDelivered)
}
func (h *ChatHandler) receipt(w http.ResponseWriter, r *http.Request, apply func(ctx context.Context, userID, convID string, seq int64) error) {
var req receiptReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := apply(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.Seq); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type muteReq struct {
// Duration is one of 8h, 1w, always, off.
Duration string `json:"duration"`
}
// SetMute — POST /chat/conversations/{id}/mute
//
// Muting silences alerts for the caller only; the thread keeps receiving
// messages and its unread count keeps climbing.
func (h *ChatHandler) SetMute(w http.ResponseWriter, r *http.Request) {
var req muteReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
until, err := h.svc.SetMute(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.Duration)
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]any{"muted_until": until})
}
// Message — GET /chat/conversations/{id}/messages/{messageID}
func (h *ChatHandler) Message(w http.ResponseWriter, r *http.Request) {
msg, err := h.svc.Message(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), chi.URLParam(r, "messageID"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, msg)
}
// MessageReactions — GET /chat/conversations/{id}/messages/{messageID}/reactions
//
// Only read when someone opens the list; the thread itself renders from the
// grouped counts that ride along with each message.
func (h *ChatHandler) MessageReactions(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.MessageReactions(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "messageID"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
// Stickers — GET /chat/stickers. What the caller may send.
func (h *ChatHandler) Stickers(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.Stickers(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
type saveStickerReq struct {
Key string `json:"key"`
}
// SaveSticker — POST /chat/conversations/{id}/messages/{messageID}/sticker
//
// Keyed to the message it was sent on, not just the sticker key: that is what
// proves the caller was actually sent it rather than guessing at the catalog.
func (h *ChatHandler) SaveSticker(w http.ResponseWriter, r *http.Request) {
var req saveStickerReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SaveSticker(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID"), req.Key); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// Typing — POST /chat/conversations/{id}/typing
//
// Fire-and-forget: nothing is stored and the response carries nothing, because
// a typing indicator is stale within seconds of being sent.
func (h *ChatHandler) Typing(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Typing(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
type reactReq struct {
Emoji string `json:"emoji"`
}
// React — PUT /chat/conversations/{id}/messages/{messageID}/reaction.
// Replaces any previous choice.
func (h *ChatHandler) React(w http.ResponseWriter, r *http.Request) {
var req reactReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
// The message id is authoritative: React re-derives the conversation from
// it and authorizes there, so the id in the path can't be used to react
// into a thread the caller isn't in.
if err := h.svc.React(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID"), req.Emoji); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// Unreact — DELETE /chat/conversations/{id}/messages/{messageID}/reaction.
// Idempotent.
func (h *ChatHandler) Unreact(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Unreact(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "messageID")); err != nil {
writeChatError(w, err)
return
}
response.NoContent(w)
}
// Receipts — GET /chat/conversations/{id}/receipts
func (h *ChatHandler) Receipts(w http.ResponseWriter, r *http.Request) {
rc, err := h.svc.Receipts(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, rc)
}
func atoiOr(s string, fallback int) int {
if s == "" {
return fallback
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return fallback
}
return n
}
+156
View File
@@ -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)
}
+25 -2
View File
@@ -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) {
+18
View File
@@ -68,3 +68,21 @@ func (h *FeedbackHandler) Submit(w http.ResponseWriter, r *http.Request) {
}
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)
}
+114
View File
@@ -0,0 +1,114 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"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"
)
// FeedbackCategoryHandler serves the /feedback form's categories: the
// public list, and their management in the admin panel.
type FeedbackCategoryHandler struct {
svc *service.FeedbackCategoryService
}
func NewFeedbackCategoryHandler(svc *service.FeedbackCategoryService) *FeedbackCategoryHandler {
return &FeedbackCategoryHandler{svc: svc}
}
func writeCategoryError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrFeedbackCategoryNotFound):
response.Error(w, http.StatusNotFound, err.Error())
case errors.Is(err, repository.ErrFeedbackCategoryInUse),
errors.Is(err, repository.ErrFeedbackCategoryExists),
errors.Is(err, service.ErrCategoryLastActive):
response.Error(w, http.StatusConflict, err.Error())
case errors.Is(err, service.ErrCategoryLabel), errors.Is(err, service.ErrCategoryDescription):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "feedback category request failed")
}
}
// Public — GET /feedback/categories: the form's options, in order.
func (h *FeedbackCategoryHandler) Public(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.Active(r.Context())
if err != nil {
writeCategoryError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
// List — GET /admin/feedback/categories: all, with usage counts.
func (h *FeedbackCategoryHandler) List(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.All(r.Context())
if err != nil {
writeCategoryError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
type categoryReq struct {
Label string `json:"label"`
Description string `json:"description"`
Archived bool `json:"archived"`
}
func (h *FeedbackCategoryHandler) Create(w http.ResponseWriter, r *http.Request) {
var req categoryReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
c, err := h.svc.Create(r.Context(), req.Label, req.Description)
if err != nil {
writeCategoryError(w, err)
return
}
response.JSON(w, http.StatusCreated, c)
}
func (h *FeedbackCategoryHandler) Update(w http.ResponseWriter, r *http.Request) {
var req categoryReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if err := h.svc.Update(r.Context(), chi.URLParam(r, "key"), req.Label, req.Description, req.Archived); err != nil {
writeCategoryError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *FeedbackCategoryHandler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "key")); err != nil {
writeCategoryError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// Reorder — PUT /admin/feedback/categories/order {"keys": [...]}.
func (h *FeedbackCategoryHandler) Reorder(w http.ResponseWriter, r *http.Request) {
var req struct {
Keys []string `json:"keys"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Keys) == 0 {
response.Error(w, http.StatusBadRequest, "keys are required")
return
}
if err := h.svc.Reorder(r.Context(), req.Keys); err != nil {
writeCategoryError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+77
View File
@@ -1,6 +1,8 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
@@ -49,3 +51,78 @@ func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request
}
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})
}
+159
View File
@@ -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)
}
+259
View File
@@ -0,0 +1,259 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"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"
)
// QuizHandler serves quiz authoring (admin) and quiz taking (users).
type QuizHandler struct {
svc *service.QuizService
}
func NewQuizHandler(svc *service.QuizService) *QuizHandler { return &QuizHandler{svc: svc} }
func writeQuizError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrQuizNotFound), errors.Is(err, service.ErrQuizUnpublished):
response.Error(w, http.StatusNotFound, "quiz not found")
case errors.Is(err, repository.ErrQuizNotStarted), errors.Is(err, repository.ErrQuizNotFinished):
response.Error(w, http.StatusConflict, err.Error())
case errors.Is(err, repository.ErrQuizNotCurrent), errors.Is(err, repository.ErrQuizAlreadyAnswer),
errors.Is(err, service.ErrQuizLocked), errors.Is(err, repository.ErrQuizTargetTaken):
response.Error(w, http.StatusConflict, err.Error())
case errors.Is(err, repository.ErrQuizBadOption), errors.Is(err, repository.ErrQuizChoiceCount),
errors.Is(err, service.ErrQuizNoCorrect),
errors.Is(err, service.ErrQuizTitle), errors.Is(err, service.ErrQuizNoQuestions),
errors.Is(err, service.ErrQuizTooMany), errors.Is(err, service.ErrQuizPrompt),
errors.Is(err, service.ErrQuizOptionCount), errors.Is(err, service.ErrQuizOptionBody),
errors.Is(err, service.ErrQuizOneCorrect), errors.Is(err, service.ErrQuizTarget):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "quiz request failed")
}
}
// ── Admin ─────────────────────────────────────────────────────────────────
type quizOptionReq struct {
Body string `json:"body"`
IsCorrect bool `json:"is_correct"`
}
type quizQuestionReq struct {
Prompt string `json:"prompt"`
Explanation string `json:"explanation"`
Multi bool `json:"multi"`
Options []quizOptionReq `json:"options"`
}
type quizReq struct {
Title string `json:"title"`
Description string `json:"description"`
TargetURL string `json:"target_url"`
TargetTitle string `json:"target_title"`
Questions []quizQuestionReq `json:"questions"`
}
func (q quizReq) draft() service.QuizDraft {
d := service.QuizDraft{
Title: q.Title, Description: q.Description,
TargetURL: q.TargetURL, TargetTitle: q.TargetTitle,
}
for _, qq := range q.Questions {
in := repository.QuestionInput{Prompt: qq.Prompt, Explanation: qq.Explanation, Multi: qq.Multi}
for _, o := range qq.Options {
in.Options = append(in.Options, repository.OptionInput{Body: o.Body, IsCorrect: o.IsCorrect})
}
d.Questions = append(d.Questions, in)
}
return d
}
func (h *QuizHandler) AdminList(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.ListAdmin(r.Context())
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
func (h *QuizHandler) AdminGet(w http.ResponseWriter, r *http.Request) {
q, err := h.svc.GetAdmin(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, q)
}
func (h *QuizHandler) Create(w http.ResponseWriter, r *http.Request) {
var req quizReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
q, err := h.svc.Create(r.Context(), middleware.UserID(r.Context()), req.draft())
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusCreated, q)
}
func (h *QuizHandler) Update(w http.ResponseWriter, r *http.Request) {
var req quizReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
q, err := h.svc.Update(r.Context(), chi.URLParam(r, "id"), req.draft())
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, q)
}
func (h *QuizHandler) setPublished(w http.ResponseWriter, r *http.Request, on bool) {
q, err := h.svc.SetPublished(r.Context(), chi.URLParam(r, "id"), on)
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, q)
}
func (h *QuizHandler) Publish(w http.ResponseWriter, r *http.Request) { h.setPublished(w, r, true) }
func (h *QuizHandler) Unpublish(w http.ResponseWriter, r *http.Request) { h.setPublished(w, r, false) }
func (h *QuizHandler) Delete(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Delete(r.Context(), chi.URLParam(r, "id")); err != nil {
writeQuizError(w, err)
return
}
response.NoContent(w)
}
func (h *QuizHandler) Participants(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.Participants(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
func (h *QuizHandler) Responses(w http.ResponseWriter, r *http.Request) {
stats, err := h.svc.Responses(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, stats)
}
func (h *QuizHandler) ParticipantResult(w http.ResponseWriter, r *http.Request) {
res, err := h.svc.ParticipantResult(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "userID"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, res)
}
// ResetParticipant — DELETE /admin/quizzes/{id}/participants/{userID}. Deletes
// that person's attempt so the quiz is pending for them again.
func (h *QuizHandler) ResetParticipant(w http.ResponseWriter, r *http.Request) {
if err := h.svc.ResetParticipant(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "userID")); err != nil {
writeQuizError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── Users ─────────────────────────────────────────────────────────────────
// ForPath — GET /quizzes/for?path=… Signed in only; returns the quiz on that
// page with the caller's progress, or null when the page has none.
func (h *QuizHandler) ForPath(w http.ResponseWriter, r *http.Request) {
card, err := h.svc.ForPath(r.Context(), middleware.UserID(r.Context()), r.URL.Query().Get("path"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, card)
}
func (h *QuizHandler) List(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.ListPublished(r.Context(), middleware.UserID(r.Context()))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
func (h *QuizHandler) Get(w http.ResponseWriter, r *http.Request) {
card, err := h.svc.Card(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, card)
}
func (h *QuizHandler) Start(w http.ResponseWriter, r *http.Request) {
play, err := h.svc.Start(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, play)
}
func (h *QuizHandler) Answer(w http.ResponseWriter, r *http.Request) {
var req struct {
QuestionID string `json:"question_id"`
OptionIDs []string `json:"option_ids"`
// Single-option form, still accepted from older clients.
OptionID string `json:"option_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.QuestionID == "" {
response.Error(w, http.StatusBadRequest, "question_id and option_ids are required")
return
}
if len(req.OptionIDs) == 0 && req.OptionID != "" {
req.OptionIDs = []string{req.OptionID}
}
if len(req.OptionIDs) == 0 {
response.Error(w, http.StatusBadRequest, "question_id and option_ids are required")
return
}
play, err := h.svc.Answer(r.Context(), middleware.UserID(r.Context()),
chi.URLParam(r, "id"), req.QuestionID, req.OptionIDs)
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, play)
}
// Result — GET /quizzes/{id}/result. The caller's own; the user id is taken
// from the session, so there is no way to ask for anyone else's.
func (h *QuizHandler) Result(w http.ResponseWriter, r *http.Request) {
res, err := h.svc.Result(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeQuizError(w, err)
return
}
response.JSON(w, http.StatusOK, res)
}
+75
View File
@@ -0,0 +1,75 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// ReminderHandler serves the sādhana-reminder preference + the timezone the
// scheduler fires against. All routes require auth.
type ReminderHandler struct {
svc *service.ReminderService
}
func NewReminderHandler(svc *service.ReminderService) *ReminderHandler {
return &ReminderHandler{svc: svc}
}
type reminderPrefResp struct {
Enabled bool `json:"enabled"`
}
// GetPrefs — GET /sadhna/reminders. Whether reminders are on for the caller.
func (h *ReminderHandler) GetPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
enabled, err := h.svc.Enabled(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load reminder preference")
return
}
response.JSON(w, http.StatusOK, reminderPrefResp{Enabled: enabled})
}
// UpdatePrefs — PUT /sadhna/reminders { enabled }. Toggles reminders.
func (h *ReminderHandler) UpdatePrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetEnabled(r.Context(), uid, req.Enabled); err != nil {
response.Error(w, http.StatusInternalServerError, "could not update reminder preference")
return
}
response.JSON(w, http.StatusOK, reminderPrefResp{Enabled: req.Enabled})
}
// SetTimezone — POST /sadhna/timezone { timezone }. The browser reports its
// IANA zone so reminders fire at local time.
func (h *ReminderHandler) SetTimezone(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req struct {
Timezone string `json:"timezone"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetTimezone(r.Context(), uid, req.Timezone); err != nil {
if errors.Is(err, service.ErrInvalidTimezone) {
response.Error(w, http.StatusBadRequest, "invalid timezone")
return
}
response.Error(w, http.StatusInternalServerError, "could not save timezone")
return
}
response.NoContent(w)
}
+232
View File
@@ -0,0 +1,232 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"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/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.
// OpenGift — POST /sadhna/rewards/gifts/{key}/open
//
// Called after the celebration is dismissed. Idempotent: opening an already
// opened gift changes nothing.
func (h *SadhnaHandler) OpenGift(w http.ResponseWriter, r *http.Request) {
if err := h.svc.OpenGift(r.Context(),
middleware.UserID(r.Context()), chi.URLParam(r, "key")); err != nil {
response.Error(w, http.StatusInternalServerError, "could not open that gift")
return
}
response.NoContent(w)
}
// GiftableRewards — GET /admin/reward-gifts/catalog
func (h *SadhnaHandler) GiftableRewards(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.GiftableRewards(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load the catalog")
return
}
response.JSON(w, http.StatusOK, list)
}
// DeleteCustomGift — DELETE /admin/reward-gifts/{key}
func (h *SadhnaHandler) DeleteCustomGift(w http.ResponseWriter, r *http.Request) {
if err := h.svc.DeleteCustomGift(r.Context(), chi.URLParam(r, "key")); err != nil {
if errors.Is(err, repository.ErrRewardNotFound) {
response.Error(w, http.StatusNotFound, "no such custom gift")
return
}
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.NoContent(w)
}
type customGiftReq struct {
Name string `json:"name"`
Meaning string `json:"meaning"`
ImageURL string `json:"image_url"`
}
// CreateCustomGift — POST /admin/reward-gifts/catalog
//
// Adds a sticker to the catalog. It reaches nobody until it is sent.
func (h *SadhnaHandler) CreateCustomGift(w http.ResponseWriter, r *http.Request) {
var req customGiftReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
reward, err := h.svc.CreateCustomGift(r.Context(), req.Name, req.Meaning, req.ImageURL)
if err != nil {
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusCreated, reward)
}
// PresignGiftImage — POST /admin/reward-gifts/image-presign
func (h *SadhnaHandler) PresignGiftImage(w http.ResponseWriter, r *http.Request) {
var req struct {
ContentType string `json:"content_type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, public, err := h.svc.PresignGiftImage(r.Context(), strings.TrimSpace(req.ContentType))
if err != nil {
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusOK, map[string]string{
"upload_url": upload, "image_url": public,
})
}
type giftReq struct {
RewardKey string `json:"reward_key"`
}
// GiftToEveryone — POST /admin/reward-gifts
//
// Hands one reward to every account, unopened. Idempotent, so a repeated
// click tops up only the people who don't have it rather than duplicating.
func (h *SadhnaHandler) GiftToEveryone(w http.ResponseWriter, r *http.Request) {
var req giftReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RewardKey == "" {
response.Error(w, http.StatusBadRequest, "reward_key is required")
return
}
n, err := h.svc.GiftToEveryone(r.Context(), req.RewardKey)
if err != nil {
if errors.Is(err, repository.ErrRewardNotFound) {
response.Error(w, http.StatusNotFound, "no such reward")
return
}
response.Error(w, http.StatusInternalServerError, "could not send the gift")
return
}
response.JSON(w, http.StatusOK, map[string]int64{"granted": n})
}
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)
}
+35 -16
View File
@@ -82,13 +82,18 @@ const searchStopwordsSet = "common"
// searchResultItem represents a single search result in the API response.
type searchResultItem struct {
Collection string `json:"collection"`
Slug string `json:"slug,omitempty"`
Title string `json:"title"`
Snippet string `json:"snippet"`
URL string `json:"url"`
Meta map[string]interface{} `json:"meta,omitempty"`
score int64 // internal, not serialized
Collection string `json:"collection"`
// DocID is the underlying Mongo _id hex — the indexer uses it as the
// Typesense document id. Surfaced so the admin panel can pin a specific
// item as the Vāṇī of the Day; it identifies public content, not anything
// private.
DocID string `json:"doc_id,omitempty"`
Slug string `json:"slug,omitempty"`
Title string `json:"title"`
Snippet string `json:"snippet"`
URL string `json:"url"`
Meta map[string]interface{} `json:"meta,omitempty"`
score int64 // internal, not serialized
}
// searchResponse is the shape of the data field in the API response.
@@ -203,11 +208,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 +276,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.
@@ -336,6 +354,7 @@ func hitToResultItem(collection string, hit *api.SearchResultHit) searchResultIt
item := searchResultItem{
Collection: collection,
DocID: getStringField(doc, "id"),
URL: getStringField(doc, "url"),
score: score,
}
+34
View File
@@ -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})
}
+390 -16
View File
@@ -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"
@@ -58,10 +60,11 @@ func (h *PostHandler) Report(w http.ResponseWriter, r *http.Request) {
}
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) {
@@ -78,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
@@ -86,6 +89,60 @@ 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
@@ -124,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 {
@@ -133,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)
@@ -144,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"`
}
@@ -442,10 +627,13 @@ type ProfileHandler struct {
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, graph *service.SocialGraphService) *ProfileHandler {
return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph}
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, graph *service.SocialGraphService, likes *repository.LikeRepo, 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) {
@@ -460,6 +648,16 @@ 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")
@@ -480,16 +678,49 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
"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. When an
// authenticated viewer hits this route (via OptionalAuth) each post
@@ -497,27 +728,82 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
// 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 != "" && len(posts) > 0 {
if viewerID != "" {
ids := make([]string, 0, len(posts))
for i := range posts {
// Skip own posts — can't reshare yourself, and the swap
// already triggers on isOwner.
if posts[i].UserID != viewerID {
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 {
mapSocialErr(w, err)
return
return err
}
for i := range posts {
if states[posts[i].ID] {
@@ -526,7 +812,76 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
}
}
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
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})
}
// ---------------------------------------------------------------------------
@@ -537,10 +892,13 @@ 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):
@@ -572,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
+146
View File
@@ -0,0 +1,146 @@
package handler
import (
"context"
"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
// chat records presence so a message doesn't push to someone who is
// already looking at the app. Optional.
chat ChatPresence
logger *zap.Logger
}
// ChatPresence is the slice of ChatService this handler needs — narrow so the
// webhook doesn't depend on the whole chat surface.
type ChatPresence interface {
MarkPresence(ctx context.Context, userID string, online bool)
}
func (h *SoketiWebhookHandler) presence(ctx context.Context, userID string, online bool) {
if h.chat == nil {
return
}
h.chat.MarkPresence(ctx, userID, online)
}
func NewSoketiWebhookHandler(appKey, appSecret string, feed *repository.FeedRepo, chat ChatPresence, logger *zap.Logger) *SoketiWebhookHandler {
return &SoketiWebhookHandler{
appKey: appKey, appSecret: appSecret, feed: feed, chat: chat, 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 {
uid, ok := userIDFromNotifChannel(e.Channel)
if !ok {
continue
}
switch e.Name {
case "channel_occupied":
// This user has a live connection, so chat push is suppressed
// for them — they will see the message in the app.
h.presence(ctx, uid, true)
case "channel_vacated":
// Their last tab closed: they are offline. Evict the feed
// seen-set and let chat push resume.
h.feed.Reset(ctx, uid)
h.presence(ctx, uid, false)
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))
}
+31
View File
@@ -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)
}
}
}
+172
View File
@@ -0,0 +1,172 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// StaffHandler serves /admin/me and the superadmin-only staff and role screens.
type StaffHandler struct {
svc *service.StaffService
}
func NewStaffHandler(svc *service.StaffService) *StaffHandler { return &StaffHandler{svc: svc} }
func writeStaffError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrNotStaff), errors.Is(err, repository.ErrRoleNotFound),
errors.Is(err, repository.ErrUserGone):
response.Error(w, http.StatusNotFound, err.Error())
case errors.Is(err, service.ErrStaffSelf), errors.Is(err, service.ErrLastSuperadmin),
errors.Is(err, service.ErrRoleSystem), errors.Is(err, repository.ErrRoleInUse),
errors.Is(err, repository.ErrRoleKeyTaken):
response.Error(w, http.StatusConflict, err.Error())
case errors.Is(err, service.ErrRoleBadPermission), errors.Is(err, service.ErrRoleName),
errors.Is(err, service.ErrRoleNoPermissions):
response.Error(w, http.StatusBadRequest, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "staff request failed")
}
}
// Me — GET /admin/me. The caller's role and effective permissions, which the
// panel uses to hide what they can't do. Hiding is a courtesy: every route
// enforces its own permission regardless.
func (h *StaffHandler) Me(w http.ResponseWriter, r *http.Request) {
s := middleware.Staff(r.Context())
response.JSON(w, http.StatusOK, service.Me{
RoleKey: s.RoleKey, RoleName: s.RoleName, Permissions: s.Perms.List(),
})
}
// Catalog — GET /admin/permissions. What a custom role may be built from.
func (h *StaffHandler) Catalog(w http.ResponseWriter, _ *http.Request) {
response.JSON(w, http.StatusOK, rbac.Catalog)
}
func (h *StaffHandler) List(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.List(r.Context())
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, list)
}
type grantReq struct {
Handle string `json:"handle"`
RoleID string `json:"role_id"`
}
// Grant — POST /admin/staff
func (h *StaffHandler) Grant(w http.ResponseWriter, r *http.Request) {
var req grantReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Handle == "" || req.RoleID == "" {
response.Error(w, http.StatusBadRequest, "handle and role_id are required")
return
}
m, err := h.svc.Grant(r.Context(), middleware.UserID(r.Context()), req.Handle, req.RoleID)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, m)
}
// ChangeRole — PATCH /admin/staff/{id}
func (h *StaffHandler) ChangeRole(w http.ResponseWriter, r *http.Request) {
var req struct {
RoleID string `json:"role_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RoleID == "" {
response.Error(w, http.StatusBadRequest, "role_id is required")
return
}
m, err := h.svc.ChangeRole(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), req.RoleID)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, m)
}
// Revoke — DELETE /admin/staff/{id}
func (h *StaffHandler) Revoke(w http.ResponseWriter, r *http.Request) {
if err := h.svc.Revoke(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id")); err != nil {
writeStaffError(w, err)
return
}
response.NoContent(w)
}
func (h *StaffHandler) setBlocked(w http.ResponseWriter, r *http.Request, blocked bool) {
m, err := h.svc.SetBlocked(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"), blocked)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, m)
}
// Block — POST /admin/staff/{id}/block ; Unblock — DELETE the same.
func (h *StaffHandler) Block(w http.ResponseWriter, r *http.Request) { h.setBlocked(w, r, true) }
func (h *StaffHandler) Unblock(w http.ResponseWriter, r *http.Request) { h.setBlocked(w, r, false) }
func (h *StaffHandler) Roles(w http.ResponseWriter, r *http.Request) {
roles, err := h.svc.Roles(r.Context())
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, roles)
}
type adminRoleReq struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
}
func (h *StaffHandler) CreateRole(w http.ResponseWriter, r *http.Request) {
var req adminRoleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
ro, err := h.svc.CreateRole(r.Context(), req.Name, req.Description, req.Permissions)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusCreated, ro)
}
func (h *StaffHandler) UpdateRole(w http.ResponseWriter, r *http.Request) {
var req adminRoleReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
ro, err := h.svc.UpdateRole(r.Context(), chi.URLParam(r, "id"), req.Name, req.Description, req.Permissions)
if err != nil {
writeStaffError(w, err)
return
}
response.JSON(w, http.StatusOK, ro)
}
func (h *StaffHandler) DeleteRole(w http.ResponseWriter, r *http.Request) {
if err := h.svc.DeleteRole(r.Context(), chi.URLParam(r, "id")); err != nil {
writeStaffError(w, err)
return
}
response.NoContent(w)
}
+16 -48
View File
@@ -1,14 +1,11 @@
package handler
import (
"errors"
"html/template"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
)
// StatsHandler renders a single-page HTML snapshot of the system —
@@ -17,17 +14,21 @@ import (
// public per current product call. If that ever needs to change, wrap
// the route in middleware in server.go.
//
// It also handles the "Mark resolved" form POST per feedback row, which
// stamps resolved_at (one-way) and fires the author thank-you email via
// the feedback service.
// 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
svc *service.FeedbackService
}
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo, svc *service.FeedbackService) *StatsHandler {
return &StatsHandler{stats: stats, feedback: feedback, svc: svc}
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) {
@@ -55,33 +56,6 @@ func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
}
}
// AcknowledgeFeedback handles the per-row "Acknowledge" form POST. Marks
// the row acknowledged (one-way) and emails the author that we've seen
// it and started work, then 303-redirects back to /stats. Already-
// acknowledged or unknown ids redirect silently.
func (h *StatsHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.svc.Acknowledge(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) {
http.Error(w, "acknowledge failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/stats", http.StatusSeeOther)
}
// ResolveFeedback handles the per-row "Mark resolved" form POST. It marks
// the row resolved (one-way) and fires the author thank-you email, then
// 303-redirects back to /stats so a refresh doesn't re-submit. Already-
// resolved or unknown ids redirect silently — the desired end state is
// already true.
func (h *StatsHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.svc.Resolve(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyResolved) {
http.Error(w, "resolve failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/stats", http.StatusSeeOther)
}
var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{
"fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
}).Parse(statsHTML))
@@ -115,6 +89,7 @@ h2 { font-size: 1.05rem; margin-top: 2rem; margin-bottom: 0.5rem; border-bottom:
.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 }
@@ -158,8 +133,8 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
<div class="author">
{{if .AuthorHandle}}
@{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}}
{{else if .ContactEmail}}
anonymous · <code>{{.ContactEmail}}</code>
{{else if .HasContactEmail}}
anonymous · reply address on file
{{else}}
anonymous
{{end}}
@@ -168,17 +143,10 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
{{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}}
{{if .AcknowledgedAt}}
<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>
{{else}}
<form method="post" action="/stats/feedback/{{.ID}}/acknowledge" onsubmit="return confirm('Acknowledge this feedback? The author will be emailed that we have seen it and started work. This cannot be undone.');">
<button type="submit" class="ack-btn">Acknowledge</button>
</form>
{{end}}
<form method="post" action="/stats/feedback/{{.ID}}/resolve" onsubmit="return confirm('Mark this feedback as resolved? This cannot be undone, and the author will be emailed.');">
<button type="submit" class="resolve-btn">Mark resolved</button>
</form>
<span class="pending-badge">Pending</span>
{{end}}
</div>
</div>
+46
View File
@@ -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)
}
}
}
+24
View File
@@ -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 {
+27 -10
View File
@@ -30,6 +30,7 @@ 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"`
@@ -57,6 +58,7 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
Name: req.Name,
AvatarURL: req.AvatarURL,
Handle: req.Handle,
Bio: req.Bio,
Country: req.Country,
Sampradaya: req.Sampradaya,
Gender: req.Gender,
@@ -94,21 +96,31 @@ type userSearchItem struct {
// the query carries an "@" prefix.
func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
response.JSON(w, http.StatusOK, map[string]any{
"query": "",
"results": []userSearchItem{},
})
return
}
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, err := h.svc.Search(r.Context(), q, limit)
users, total, err := h.svc.Search(r.Context(), q, limit, (page-1)*limit)
if err != nil {
response.Error(w, http.StatusInternalServerError, "search failed")
return
@@ -122,9 +134,14 @@ func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) {
AvatarURL: u.AvatarURL,
})
}
totalPages := (total + limit - 1) / limit
response.JSON(w, http.StatusOK, map[string]any{
"query": q,
"results": items,
"query": q,
"results": items,
"total": total,
"page": page,
"limit": limit,
"total_pages": totalPages,
})
}
+154
View File
@@ -0,0 +1,154 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"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"
)
// 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.
type setVaniReq struct {
// Day as YYYY-MM-DD (UTC). Empty means today.
Day string `json:"day"`
Kind string `json:"kind"`
// RefID is the content's Mongo _id hex, which search returns as doc_id.
RefID string `json:"ref_id"`
}
// Set — POST /admin/vani. Pins a specific item as a day's Vāṇī.
func (h *VaniHandler) Set(w http.ResponseWriter, r *http.Request) {
var req setVaniReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
day, err := parseVaniDay(req.Day)
if err != nil {
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
return
}
pick, err := h.svc.SetForDay(r.Context(), day, req.Kind, req.RefID)
if err != nil {
writeVaniError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]any{
"day": pick.Day.Format("2006-01-02"),
"kind": pick.Kind,
"ref_id": pick.RefID,
"payload": pick.Payload,
})
}
type setVaniByURLReq struct {
Day string `json:"day"`
URL string `json:"url"`
}
// SetByURL — POST /admin/vani/by-url. Pins whatever a link points at.
func (h *VaniHandler) SetByURL(w http.ResponseWriter, r *http.Request) {
var req setVaniByURLReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
day, err := parseVaniDay(req.Day)
if err != nil {
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
return
}
pick, err := h.svc.SetByURL(r.Context(), req.URL, day)
if err != nil {
writeVaniError(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]any{
"day": pick.Day.Format("2006-01-02"),
"kind": pick.Kind,
"ref_id": pick.RefID,
"payload": pick.Payload,
})
}
// Clear — DELETE /admin/vani/{day}. Hands the day back to the random picker.
func (h *VaniHandler) Clear(w http.ResponseWriter, r *http.Request) {
day, err := parseVaniDay(chi.URLParam(r, "day"))
if err != nil {
response.Error(w, http.StatusBadRequest, "day must be YYYY-MM-DD")
return
}
if err := h.svc.ClearForDay(r.Context(), day); err != nil {
writeVaniError(w, err)
return
}
response.NoContent(w)
}
// Schedule — GET /admin/vani. Decided days from today onward.
func (h *VaniHandler) Schedule(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
picks, err := h.svc.Schedule(r.Context(), limit)
if err != nil {
writeVaniError(w, err)
return
}
out := make([]map[string]any, 0, len(picks))
for _, p := range picks {
out = append(out, map[string]any{
"day": p.Day.Format("2006-01-02"),
"kind": p.Kind,
"ref_id": p.RefID,
"payload": p.Payload,
})
}
response.JSON(w, http.StatusOK, out)
}
// parseVaniDay accepts YYYY-MM-DD, or empty for today (UTC — the pick is
// keyed on a UTC calendar day so everyone sees the same item).
func parseVaniDay(s string) (time.Time, error) {
if strings.TrimSpace(s) == "" {
return time.Now().UTC().Truncate(24 * time.Hour), nil
}
return time.Parse("2006-01-02", strings.TrimSpace(s))
}
func writeVaniError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrVaniRefInvalid),
errors.Is(err, service.ErrVaniBadKind),
errors.Is(err, service.ErrVaniBadURL):
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, repository.ErrVaniRefNotFound):
response.Error(w, http.StatusNotFound, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "vāṇī request failed")
}
}
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)
}
+79
View File
@@ -0,0 +1,79 @@
package middleware
import (
"context"
"errors"
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
type staffCtxKey struct{}
// StaffContext is the caller's resolved admin access, set by RequireStaff.
type StaffContext struct {
RoleKey string
RoleName string
Perms rbac.Set
}
// Staff returns the caller's staff context, or nil outside RequireStaff.
func Staff(ctx context.Context) *StaffContext {
s, _ := ctx.Value(staffCtxKey{}).(*StaffContext)
return s
}
// RequireStaff admits accounts that hold an admin role and aren't blocked from
// the panel. It MUST run after RequireAuth.
//
// It resolves the role's permissions once per request and puts them in
// context, so the RequirePermission checks on each route are map lookups
// rather than further queries. Being staff gets you through the door only;
// every route then names the permission it needs.
func RequireStaff(staff *repository.StaffRepo) 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
}
access, err := staff.Access(r.Context(), uid)
if errors.Is(err, repository.ErrNotStaff) {
response.Error(w, http.StatusForbidden, "admin access required")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "auth check failed")
return
}
if access.Blocked {
response.Error(w, http.StatusForbidden, "your admin access has been suspended")
return
}
ctx := context.WithValue(r.Context(), staffCtxKey{}, &StaffContext{
RoleKey: access.RoleKey,
RoleName: access.RoleName,
Perms: rbac.NewSet(access.Permissions),
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequirePermission gates a route to staff holding perm. Must run inside
// RequireStaff; outside it there is no staff context and everything is refused.
func RequirePermission(perm string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s := Staff(r.Context())
if s == nil || !s.Perms.Has(perm) {
response.Error(w, http.StatusForbidden, "you don't have permission to do that")
return
}
next.ServeHTTP(w, r)
})
}
}
+47
View File
@@ -0,0 +1,47 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
)
func withStaff(perms ...string) context.Context {
return context.WithValue(context.Background(), staffCtxKey{}, &StaffContext{
RoleKey: "x", Perms: rbac.NewSet(perms),
})
}
func TestRequirePermission(t *testing.T) {
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
gate := RequirePermission("users:ban")(ok)
cases := []struct {
name string
ctx context.Context
want int
}{
{"moderator without the permission", withStaff("reports:read", "users:read"), http.StatusForbidden},
{"admin with it", withStaff("users:ban"), http.StatusNoContent},
{"superadmin wildcard", withStaff(rbac.Wildcard), http.StatusNoContent},
// Outside RequireStaff there's no staff context at all: refuse.
{"no staff context", context.Background(), http.StatusForbidden},
}
for _, c := range cases {
rec := httptest.NewRecorder()
gate.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", nil).WithContext(c.ctx))
if rec.Code != c.want {
t.Errorf("%s: status %d, want %d", c.name, rec.Code, c.want)
}
}
// Staff management is never reachable through a catalog permission.
if withStaffSet("users:ban", "quizzes:write").Has(rbac.ManageStaff) {
t.Error("a non-wildcard set holds admins:manage")
}
}
func withStaffSet(perms ...string) rbac.Set { return rbac.NewSet(perms) }
+13
View File
@@ -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)
@@ -94,6 +100,13 @@ func OptionalAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Han
}
}
// 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 {
+47
View File
@@ -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 ""
}
+132
View File
@@ -0,0 +1,132 @@
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"`
// AdminRole is the staff role key, nil for ordinary accounts.
AdminRole *string `json:"admin_role,omitempty"`
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"`
CategoryLabel string `json:"category_label"`
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"`
}
+16
View File
@@ -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"`
}
+40
View File
@@ -0,0 +1,40 @@
package model
import (
"encoding/json"
"time"
)
// Broadcast is an announcement shown to every user exactly once.
//
// Deliberately not a notification: it creates no tray rows and no per-user
// records until someone actually dismisses it. The modal is the delivery
// surface; push and the realtime nudge only tell clients to go look.
type Broadcast struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
// Entities are offset/length formatting spans, the same shape chat uses,
// so no HTML is ever stored or rendered.
Entities json.RawMessage `json:"entities"`
// BannerURL is resolved from the stored R2 key for the client.
BannerURL *string `json:"banner_url,omitempty"`
CTALabel *string `json:"cta_label,omitempty"`
CTAURL *string `json:"cta_url,omitempty"`
PublishedAt *time.Time `json:"published_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
// SeenCount is filled for the admin list only — how many people have
// dismissed it so far.
SeenCount int64 `json:"seen_count,omitempty"`
}
// Live reports whether the broadcast is currently deliverable.
func (b *Broadcast) Live(now time.Time) bool {
if b.PublishedAt == nil || b.PublishedAt.After(now) {
return false
}
return b.ExpiresAt == nil || b.ExpiresAt.After(now)
}
+302
View File
@@ -0,0 +1,302 @@
package model
import (
"encoding/json"
"time"
)
// Who may start a conversation with a user.
const (
WhoAnyone = "anyone"
WhoFriends = "friends"
WhoAccountAge = "account_age"
WhoSameGender = "same_gender"
)
// ChatSettings are a user's own chat preferences. Absence of a stored row means
// all defaults, so DefaultChatSettings and the column defaults must agree.
type ChatSettings struct {
ReadReceipts bool `json:"read_receipts"`
WhoCanMessage string `json:"who_can_message"`
MinAccountAgeMonths int `json:"min_account_age_months"`
TrainKautilya bool `json:"train_kautilya"`
// GenderSet reports whether the user has a gender on their profile. The
// same_gender option is unusable without one, so the UI needs to know.
GenderSet bool `json:"gender_set"`
}
// DefaultChatSettings mirrors the column defaults in migration 0050.
func DefaultChatSettings() ChatSettings {
return ChatSettings{
ReadReceipts: true,
WhoCanMessage: WhoAnyone,
MinAccountAgeMonths: 3,
TrainKautilya: true,
}
}
// Conversation kinds. DMs and groups fill the inbox's "Chats" tab; ai fills
// the "Kautilya" tab.
// Group send policies. Only groups honour these — a DM has no administrators
// and the Kautilya thread has one participant.
const (
SendPolicyEveryone = "everyone"
SendPolicyAdmins = "admins"
)
// Participant roles, most privileged first.
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
)
const (
ConversationDM = "dm"
ConversationGroup = "group"
ConversationAI = "ai"
)
// Message kinds describe who produced a message, not what it contains — the
// content type lives on its attachments.
const (
MessageText = "text"
MessageSystem = "system"
MessageAssistant = "assistant"
)
// Conversation is a chat thread.
type Conversation struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title *string `json:"title,omitempty"`
AvatarKey *string `json:"avatar_key,omitempty"`
// SendPolicy is "everyone" or "admins"; meaningful for groups only.
SendPolicy string `json:"send_policy"`
LastSeq int64 `json:"last_seq"`
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ChatPeer is the counterpart shown on a DM row: enough to render the inbox
// without a second round trip.
type ChatPeer struct {
ID string `json:"id"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url,omitempty"`
// Role and JoinedAt are filled for a thread's participant list; they are
// empty on the `peer` of a DM, where a role means nothing.
Role string `json:"role,omitempty"`
JoinedAt *time.Time `json:"joined_at,omitempty"`
}
// MessagePreview is the truncated last-message line on an inbox row.
type MessagePreview struct {
Seq int64 `json:"seq"`
SenderID *string `json:"sender_id,omitempty"`
SenderName string `json:"sender_name"`
Kind string `json:"kind"`
Body string `json:"body"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// InboxItem is one row of the chat inbox.
type InboxItem struct {
Conversation
Unread int64 `json:"unread"`
// MutedUntil is non-nil while the caller has this thread muted. Unread
// still accrues — muting silences the alert, it does not hide the chat.
MutedUntil *time.Time `json:"muted_until,omitempty"`
Peer *ChatPeer `json:"peer,omitempty"` // DMs only
LastMessage *MessagePreview `json:"last_message,omitempty"` // nil on an empty thread
}
// ChatRelationship is how two people in a DM stand with each other — what the
// chat profile shows above the shared-media tabs.
type ChatRelationship struct {
// Following: the caller follows the peer. FollowedBy: the reverse. Both
// true is mutual; one true is a one-way follow.
Following bool `json:"following"`
FollowedBy bool `json:"followed_by"`
Blocked bool `json:"blocked"`
BlockedBy bool `json:"blocked_by"`
// FriendsSince is when the first follow between them was accepted — the
// "friends since" line. Nil when neither follows the other.
FriendsSince *time.Time `json:"friends_since,omitempty"`
}
// ConversationDetail is everything a thread screen needs in one request:
// the thread, who is in it, and the caller's own state. It replaces scanning
// the inbox to find a single conversation.
type ConversationDetail struct {
Conversation
// Peer is the counterpart on a DM, nil otherwise.
Peer *ChatPeer `json:"peer,omitempty"`
// Participants is everyone still in the thread, the caller included, so
// the UI can name a message's sender without another lookup.
Participants []ChatPeer `json:"participants"`
Unread int64 `json:"unread"`
MutedUntil *time.Time `json:"muted_until,omitempty"`
// MyRole is the caller's role in this thread, so the UI can show admin
// controls without deriving it from the participant list.
MyRole string `json:"my_role"`
// Relationship is populated for DMs only; a group has no single peer to
// stand in a relationship with.
Relationship *ChatRelationship `json:"relationship,omitempty"`
}
// Participant is a user's membership of a conversation, including the two
// cursors that drive tick state.
type Participant struct {
ConversationID string `json:"conversation_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
LastReadSeq int64 `json:"last_read_seq"`
LastDeliveredSeq int64 `json:"last_delivered_seq"`
MutedUntil *time.Time `json:"muted_until,omitempty"`
JoinedAt time.Time `json:"joined_at"`
LeftAt *time.Time `json:"left_at,omitempty"`
}
// Attachment kinds.
const (
AttachmentImage = "image"
AttachmentVideo = "video"
AttachmentAudio = "audio"
AttachmentFile = "file"
AttachmentLink = "link"
AttachmentEntity = "entity"
// AttachmentQuote is a passage shared from the library: url is an
// app-relative deep link back to the phrase, title the source reference,
// description the passage itself.
AttachmentQuote = "quote"
// AttachmentSticker is reward artwork: image_url and title carry the art
// and its name, entity_id the reward key a recipient can save by.
AttachmentSticker = "sticker"
)
// Entity types a message can reference.
const (
EntityPost = "post"
// EntitySticker marks a sticker attachment's entity_id as a reward key.
EntitySticker = "sticker"
)
// Attachment is a media file, link preview or shared entity on a message.
//
// Media carries an R2 key rather than a URL: chat media lives in the private
// bucket and is only ever reachable through a participant-checked, short-lived
// presigned GET, so there is no URL to store.
type Attachment struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
Ord int `json:"ord"`
Kind string `json:"kind"`
// Media
Mime *string `json:"mime,omitempty"`
SizeBytes *int64 `json:"size_bytes,omitempty"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
DurationMs *int `json:"duration_ms,omitempty"`
Blurhash *string `json:"blurhash,omitempty"`
FileName *string `json:"file_name,omitempty"`
// Link preview. Metadata is fetched by the BFF, never supplied by a
// client — otherwise any link could be made to claim anything.
URL *string `json:"url,omitempty"`
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
SiteName *string `json:"site_name,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
// Shared entity (a post, for now). EntityType/EntityID are the durable
// reference; Entity is resolved per viewer at read time, so the same row
// renders differently — or not at all — depending on who is looking.
EntityType *string `json:"entity_type,omitempty"`
EntityID *string `json:"entity_id,omitempty"`
Entity json.RawMessage `json:"entity,omitempty"`
// EntityUnavailable is set when the entity was deleted, hidden, or the
// viewer has since lost access. The client renders a tombstone.
EntityUnavailable bool `json:"entity_unavailable,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ReplyPreview is the quoted snippet shown above a reply. Deliberately small:
// the bubble needs a name and a line of text, not the whole message.
type ReplyPreview struct {
ID string `json:"id"`
Seq int64 `json:"seq"`
SenderID *string `json:"sender_id,omitempty"`
SenderName string `json:"sender_name"`
Body string `json:"body"`
Deleted bool `json:"deleted"`
}
// Message is one chat message. Entities carries the formatting spans produced
// by ParseEntities; it is always a JSON array, never null.
type Message struct {
ID string `json:"id"`
ConversationID string `json:"conversation_id"`
Seq int64 `json:"seq"`
SenderID *string `json:"sender_id,omitempty"`
Kind string `json:"kind"`
Body string `json:"body"`
Entities json.RawMessage `json:"entities"`
ClientMsgID *string `json:"client_msg_id,omitempty"`
ReplyToID *string `json:"reply_to_id,omitempty"`
ForwardedFromID *string `json:"forwarded_from_id,omitempty"`
EditedAt *time.Time `json:"edited_at,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
// ReplyTo is hydrated on read when this message replies to another in the
// SAME conversation. Nil otherwise.
ReplyTo *ReplyPreview `json:"reply_to,omitempty"`
// Attachments is always present (possibly empty) so clients need no nil
// check before iterating.
Attachments []Attachment `json:"attachments"`
// Reactions is always present (possibly empty), same contract as
// Attachments — clients render the chip row without a nil check.
Reactions []Reaction `json:"reactions"`
}
// Sticker is one piece of reward artwork, as it appears in the picker and on
// a message.
type Sticker struct {
Key string `json:"key"`
Name string `json:"name"`
ImageURL string `json:"image_url"`
// Saved is true when it came from a chat rather than from sādhana, so the
// picker can say where it came from.
Saved bool `json:"saved"`
}
// ReactionDetail is one person's reaction, for the "who reacted" list.
type ReactionDetail struct {
User ChatPeer `json:"user"`
Emoji string `json:"emoji"`
CreatedAt time.Time `json:"created_at"`
}
// Reaction is one emoji grouped across the people who chose it.
//
// Grouped rather than listed per user: a bubble shows "❤️ 3", and sending the
// full membership of every reaction would grow with the room.
type Reaction struct {
Emoji string `json:"emoji"`
Count int `json:"count"`
// Mine marks the viewer's own choice, so the chip can be highlighted and
// tapping it toggles off.
Mine bool `json:"mine"`
}
// Receipts are the conversation-wide delivery cursors used to draw ticks on
// the sender's own messages. Both are the MINIMUM across the other
// participants, so in a group a message is "read" only once everyone has read
// it — matching what users expect from the blue double tick.
type Receipts struct {
DeliveredSeq int64 `json:"delivered_seq"`
ReadSeq int64 `json:"read_seq"`
}
+31 -1
View File
@@ -18,7 +18,10 @@ type Feedback struct {
ID string `json:"id"`
UserID *string `json:"user_id,omitempty"`
Category string `json:"category"`
Message string `json:"message"`
// CategoryLabel is the category's current display name (falls back to
// the key if the category was ever removed).
CategoryLabel string `json:"category_label"`
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
@@ -31,7 +34,34 @@ type Feedback struct {
// 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
}
// FeedbackCategory is one option on the /feedback form, managed from the
// admin panel. Key is what feedback.category stores and never changes.
type FeedbackCategory struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
SortOrder int `json:"sort_order"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
// UsageCount is admin-only: how much feedback is filed under it.
UsageCount int `json:"usage_count,omitempty"`
}
+51
View File
@@ -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)
}
+137
View File
@@ -0,0 +1,137 @@
package model
import "time"
// Quiz target kinds.
const (
QuizChapter = "chapter"
QuizLecture = "lecture"
QuizConversation = "conversation"
QuizStandalone = "standalone"
)
// Attempt states, as seen by the person taking the quiz.
const (
QuizNotStarted = "not_started"
QuizInProgress = "in_progress"
QuizCompleted = "completed"
)
// Quiz is a set of multiple-choice questions, optionally tied to a page.
type Quiz struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
TargetKind string `json:"target_kind"`
TargetPath *string `json:"target_path,omitempty"`
TargetTitle string `json:"target_title"`
// TargetYear is the lecture or conversation's year, for grouping.
TargetYear *int `json:"target_year,omitempty"`
PublishedAt *time.Time `json:"published_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
QuestionCount int `json:"question_count"`
// Admin-only counters.
AttemptCount int `json:"attempt_count,omitempty"`
CompletedCount int `json:"completed_count,omitempty"`
Questions []QuizQuestion `json:"questions,omitempty"`
}
// QuizQuestion is one question. Prompt and option bodies carry the same
// formatting markers as the rest of the admin text.
type QuizQuestion struct {
ID string `json:"id"`
Ord int `json:"ord"`
Prompt string `json:"prompt"`
Explanation string `json:"explanation,omitempty"`
// Multi questions ask for every correct option ("select all that apply").
Multi bool `json:"multi"`
Options []QuizOption `json:"options"`
}
// QuizOption is one choice. IsCorrect is nil whenever the reader must not
// know it yet — every response to someone mid-attempt.
type QuizOption struct {
ID string `json:"id"`
Ord int `json:"ord"`
Body string `json:"body"`
IsCorrect *bool `json:"is_correct,omitempty"`
}
// QuizStatus is the caller's progress on a quiz.
type QuizStatus struct {
State string `json:"state"`
Answered int `json:"answered"`
Total int `json:"total"`
Score *int `json:"score,omitempty"`
}
// QuizCard is a quiz plus the caller's progress, for buttons and lists.
type QuizCard struct {
Quiz
My QuizStatus `json:"my"`
}
// QuizPlay is the next step of an attempt: the current question, or Done.
type QuizPlay struct {
QuizID string `json:"quiz_id"`
Title string `json:"title"`
Answered int `json:"answered"`
Total int `json:"total"`
Done bool `json:"done"`
Question *QuizQuestion `json:"question,omitempty"`
}
// QuizResultItem is one question on a results screen.
type QuizResultItem struct {
QuizQuestion
ChosenOptionIDs []string `json:"chosen_option_ids"`
IsCorrect bool `json:"is_correct"`
ResponseMs *int `json:"response_ms,omitempty"`
}
// QuizResult is a finished attempt.
type QuizResult struct {
Quiz Quiz `json:"quiz"`
Score int `json:"score"`
Total int `json:"total"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
TotalMs int `json:"total_ms"`
Items []QuizResultItem `json:"items"`
}
// QuizParticipant is one person's attempt, for the admin participants list.
type QuizParticipant struct {
UserID string `json:"user_id"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url,omitempty"`
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Answered int `json:"answered"`
Score int `json:"score"`
Total int `json:"total"`
TotalMs int `json:"total_ms"`
}
// QuizOptionStat is how often one option was chosen.
type QuizOptionStat struct {
QuizOption
Picks int `json:"picks"`
}
// QuizQuestionStat aggregates every response to one question.
type QuizQuestionStat struct {
ID string `json:"id"`
Ord int `json:"ord"`
Prompt string `json:"prompt"`
Multi bool `json:"multi"`
Answered int `json:"answered"`
Correct int `json:"correct"`
AvgMs int `json:"avg_ms"`
MedianMs int `json:"median_ms"`
Options []QuizOptionStat `json:"options"`
}
+104
View File
@@ -0,0 +1,104 @@
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 is the streak day this was earned on. Nil for an admin gift,
// which isn't tied to a streak at all.
Milestone *int `json:"milestone"`
GrantedAt time.Time `json:"granted_at"`
Reward SadhnaReward `json:"reward"`
// Source is "streak" or "gift".
Source string `json:"source"`
// Opened is false while a gift is still wrapped — the gallery renders
// those blurred until the devotee opens them.
Opened bool `json:"opened"`
}
// 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"`
}
+33
View File
@@ -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
}
+113 -7
View File
@@ -20,6 +20,11 @@ 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"`
@@ -29,16 +34,78 @@ type Post struct {
// 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"`
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.
@@ -79,12 +146,17 @@ 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,
@@ -111,6 +183,15 @@ type RelationshipState struct {
RequestsYou bool `json:"requests_you"`
Blocked bool `json:"blocked"`
BlockedBy bool `json:"blocked_by"`
// CanMessage reports whether the viewer may START a conversation with this
// profile, given the target's "who can message you" setting and any block.
// It lets the UI decide whether to offer a Message button rather than
// offering one that fails on tap.
CanMessage bool `json:"can_message"`
// MessageRestriction names the target's rule when CanMessage is false, so
// the UI can explain instead of silently hiding the button. Empty when
// messaging is allowed, or "blocked" when a block is the reason.
MessageRestriction string `json:"message_restriction,omitempty"`
}
// Follow is one row of the follows table — a directed, request-based
@@ -167,7 +248,32 @@ type Notification struct {
CreatedAt time.Time `json:"created_at"`
ReadAt *time.Time `json:"read_at,omitempty"`
// Aggregation: for post_like, PostID is the liked post and ActorCount is
// how many likers have aggregated into this single row (the frontend
// renders "X and N-1 others liked your post"). Other kinds: PostID nil,
// ActorCount 1.
PostID *string `json:"post_id,omitempty"`
ActorCount int `json:"actor_count"`
ActorName *string `json:"actor_name,omitempty"`
ActorHandle *string `json:"actor_handle,omitempty"`
ActorAvatar *string `json:"actor_avatar_url,omitempty"`
// 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"`
}
+37
View File
@@ -0,0 +1,37 @@
package model
import "time"
// AdminRole is a named set of admin-panel permissions.
type AdminRole struct {
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
IsSystem bool `json:"is_system"`
Permissions []string `json:"permissions"`
// MemberCount is how many staff hold the role, for the roles screen.
MemberCount int `json:"member_count"`
CreatedAt time.Time `json:"created_at"`
}
// StaffMember is one account with admin-panel access.
type StaffMember struct {
UserID string `json:"user_id"`
Handle string `json:"handle"`
Name string `json:"name"`
Email *string `json:"email,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
RoleID string `json:"role_id"`
RoleKey string `json:"role_key"`
RoleName string `json:"role_name"`
BlockedAt *time.Time `json:"blocked_at,omitempty"`
}
// StaffAccess is what the admin middleware needs about the caller.
type StaffAccess struct {
RoleKey string
RoleName string
Permissions []string
Blocked bool
}
+49 -4
View File
@@ -12,6 +12,17 @@ import (
// pointer because OAuth-only users have no password — *PasswordHash == nil
// means "this account can only sign in via a linked OAuth provider".
//
// UserSummary is the lightweight projection used in follower / following
// lists — just enough to render a row and link to the profile.
type UserSummary struct {
// 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.
@@ -31,6 +42,23 @@ type User struct {
// 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.
@@ -60,19 +88,36 @@ type User struct {
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.
// 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"`
HasPassword bool `json:"has_password"`
IsDirectDisciple bool `json:"is_direct_disciple"`
Limits UserLimits `json:"limits"`
}{
alias: alias(u),
HasPassword: u.PasswordHash != nil,
alias: alias(u),
HasPassword: u.PasswordHash != nil,
IsDirectDisciple: u.DiscipleID != nil && *u.DiscipleID != "",
Limits: u.EffectiveLimits(),
})
}
+25
View File
@@ -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
}
+110
View File
@@ -0,0 +1,110 @@
// Package rbac is the admin panel's permission catalog.
//
// Permissions are "resource:action" strings. The catalog lives in code, not in
// the database, so a permission can't exist without a route enforcing it, and
// adding one is a reviewed change. Roles (in admin_roles) are just named sets
// of these strings.
package rbac
import "slices"
// Wildcard grants everything. Only the seeded superadmin role carries it.
const Wildcard = "*"
// ManageStaff is held by superadmins alone: granting, changing, revoking and
// blocking other staff. It is deliberately absent from Catalog, so no custom
// role can ever be given it.
const ManageStaff = "admins:manage"
// Permission is one grantable capability.
type Permission struct {
Key string `json:"key"`
Group string `json:"group"`
Label string `json:"label"`
}
// Catalog is every permission a custom role may hold, grouped for the editor.
var Catalog = []Permission{
{"stats:read", "Overview", "View dashboard stats"},
{"users:read", "Users", "View users"},
{"users:notify", "Users", "Send users a message"},
{"users:ban", "Users", "Ban and unban users"},
{"users:limits", "Users", "Change bookmark and collection limits"},
{"disciples:manage", "Users", "Link and unlink direct disciples"},
{"posts:moderate", "Moderation", "Hide, delete and restore posts"},
{"comments:moderate", "Moderation", "Delete and restore comments"},
{"collections:read", "Moderation", "View collections"},
{"reports:read", "Moderation", "View reports"},
{"reports:resolve", "Moderation", "Resolve reports"},
{"feedback:read", "Feedback", "View feedback"},
{"feedback:reply", "Feedback", "Reply to feedback"},
{"feedback:resolve", "Feedback", "Acknowledge and resolve feedback"},
{"feedback:categories", "Feedback", "Manage feedback categories"},
{"vani:read", "Vāṇī of the Day", "View the schedule"},
{"vani:write", "Vāṇī of the Day", "Pin and clear days"},
{"rewards:read", "Rewards", "View the reward catalog"},
{"rewards:write", "Rewards", "Add custom gifts"},
{"rewards:send", "Rewards", "Send gifts to everyone"},
{"rewards:delete", "Rewards", "Delete custom gifts"},
{"broadcasts:read", "Announcements", "View announcements"},
{"broadcasts:write", "Announcements", "Create and delete drafts"},
{"broadcasts:publish", "Announcements", "Publish to everyone"},
{"quizzes:read", "Quizzes", "View quizzes"},
{"quizzes:write", "Quizzes", "Create, edit and publish quizzes"},
{"quizzes:delete", "Quizzes", "Delete quizzes"},
{"quizzes:results", "Quizzes", "View participants and responses"},
{"quizzes:reset", "Quizzes", "Reset a participant's attempt"},
{"short_urls:read", "Short URLs", "View short links and stats"},
}
// Valid reports whether p is a grantable permission.
func Valid(p string) bool {
return slices.ContainsFunc(Catalog, func(c Permission) bool { return c.Key == p })
}
// Set is a resolved permission set for one staff member.
type Set struct {
all bool
keys map[string]bool
}
// NewSet resolves a role's stored permissions.
func NewSet(perms []string) Set {
s := Set{keys: map[string]bool{}}
for _, p := range perms {
if p == Wildcard {
s.all = true
}
s.keys[p] = true
}
return s
}
// Has reports whether the set grants p.
func (s Set) Has(p string) bool { return s.all || s.keys[p] }
// List is the effective permissions, for the admin UI to decide what to show.
// A wildcard expands to the full catalog plus ManageStaff.
func (s Set) List() []string {
if s.all {
out := make([]string, 0, len(Catalog)+1)
for _, c := range Catalog {
out = append(out, c.Key)
}
return append(out, ManageStaff)
}
out := make([]string, 0, len(s.keys))
for k := range s.keys {
out = append(out, k)
}
slices.Sort(out)
return out
}
+475
View File
@@ -0,0 +1,475 @@
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,
(SELECT ro.key FROM admin_roles ro WHERE ro.id = u.admin_role_id) AS admin_role,
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, &u.AdminRole, &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, COALESCE(fc.label, 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
LEFT JOIN feedback_categories fc ON fc.key = f.category
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.CategoryLabel, &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
}
+151
View File
@@ -0,0 +1,151 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var ErrBroadcastGone = errors.New("broadcast not found")
type BroadcastRepo struct{ pg *Postgres }
func NewBroadcastRepo(pg *Postgres) *BroadcastRepo { return &BroadcastRepo{pg: pg} }
const broadcastColumns = `id, title, body, entities, banner_key, cta_label,
cta_url, published_at, expires_at, created_at`
// scanBroadcast decodes a row produced by broadcastColumns. bannerKey stays
// out of the model — the service turns it into a URL.
func scanBroadcast(row pgx.Row, b *model.Broadcast, bannerKey **string) error {
return row.Scan(&b.ID, &b.Title, &b.Body, &b.Entities, bannerKey,
&b.CTALabel, &b.CTAURL, &b.PublishedAt, &b.ExpiresAt, &b.CreatedAt)
}
type CreateBroadcastParams struct {
Title string
Body string
Entities []byte
BannerKey *string
CTALabel *string
CTAURL *string
ExpiresAt *time.Time
CreatedBy string
}
func (r *BroadcastRepo) Create(ctx context.Context, p CreateBroadcastParams) (*model.Broadcast, *string, error) {
var b model.Broadcast
var key *string
err := scanBroadcast(r.pg.Pool.QueryRow(ctx, `
INSERT INTO broadcasts (title, body, entities, banner_key, cta_label,
cta_url, expires_at, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING `+broadcastColumns,
p.Title, p.Body, p.Entities, p.BannerKey, p.CTALabel, p.CTAURL,
p.ExpiresAt, p.CreatedBy), &b, &key)
if err != nil {
return nil, nil, fmt.Errorf("create broadcast: %w", err)
}
return &b, key, nil
}
// Publish stamps published_at, making the broadcast deliverable. Idempotent:
// re-publishing keeps the original timestamp so the live window doesn't shift.
func (r *BroadcastRepo) Publish(ctx context.Context, id string) (*model.Broadcast, *string, error) {
var b model.Broadcast
var key *string
err := scanBroadcast(r.pg.Pool.QueryRow(ctx, `
UPDATE broadcasts
SET published_at = COALESCE(published_at, now()), updated_at = now()
WHERE id = $1
RETURNING `+broadcastColumns, id), &b, &key)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrBroadcastGone
}
if err != nil {
return nil, nil, fmt.Errorf("publish broadcast: %w", err)
}
return &b, key, nil
}
func (r *BroadcastRepo) Delete(ctx context.Context, id string) error {
tag, err := r.pg.Pool.Exec(ctx, `DELETE FROM broadcasts WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete broadcast: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrBroadcastGone
}
return nil
}
// Pending returns the newest live broadcast this user has not dismissed.
func (r *BroadcastRepo) Pending(ctx context.Context, userID string) (*model.Broadcast, *string, error) {
var b model.Broadcast
var key *string
err := scanBroadcast(r.pg.Pool.QueryRow(ctx, `
SELECT `+broadcastColumns+`
FROM broadcasts b
WHERE b.published_at IS NOT NULL
AND b.published_at <= now()
AND (b.expires_at IS NULL OR b.expires_at > now())
AND NOT EXISTS (
SELECT 1 FROM broadcast_views v
WHERE v.broadcast_id = b.id AND v.user_id = $1)
-- Newest first. Announcements are time-sensitive, so a stale one
-- must not gate a fresh one; dismissing this reveals the next, so
-- nothing is skipped either way — only the order differs.
ORDER BY b.published_at DESC
LIMIT 1`, userID), &b, &key)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, nil // nothing waiting is not an error
}
if err != nil {
return nil, nil, fmt.Errorf("pending broadcast: %w", err)
}
return &b, key, nil
}
// MarkSeen records a dismissal. Idempotent — two tabs can race.
func (r *BroadcastRepo) MarkSeen(ctx context.Context, id, userID string) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO broadcast_views (broadcast_id, user_id) VALUES ($1,$2)
ON CONFLICT DO NOTHING`, id, userID)
if err != nil {
return fmt.Errorf("mark broadcast seen: %w", err)
}
return nil
}
// List returns broadcasts newest first, with dismissal counts, for the admin.
func (r *BroadcastRepo) List(ctx context.Context, limit int) ([]model.Broadcast, []*string, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT `+broadcastColumns+`,
(SELECT count(*) FROM broadcast_views v WHERE v.broadcast_id = b.id)
FROM broadcasts b
ORDER BY COALESCE(b.published_at, b.created_at) DESC
LIMIT $1`, limit)
if err != nil {
return nil, nil, fmt.Errorf("list broadcasts: %w", err)
}
defer rows.Close()
out := []model.Broadcast{}
keys := []*string{}
for rows.Next() {
var b model.Broadcast
var key *string
if err := rows.Scan(&b.ID, &b.Title, &b.Body, &b.Entities, &key,
&b.CTALabel, &b.CTAURL, &b.PublishedAt, &b.ExpiresAt, &b.CreatedAt,
&b.SeenCount); err != nil {
return nil, nil, fmt.Errorf("scan broadcast: %w", err)
}
out = append(out, b)
keys = append(keys, key)
}
return out, keys, rows.Err()
}
File diff suppressed because it is too large Load Diff
+434
View File
@@ -0,0 +1,434 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/prabhupadasays/spsays-bff/internal/model"
"go.uber.org/zap"
)
// These exercise real SQL against a real Postgres — the LATERAL joins, the
// FOR UPDATE sequencing and the partial indexes are the parts most likely to be
// wrong, and none of them can be checked without a database. Skipped when no
// POSTGRES_URI is configured, so `go test ./...` still passes anywhere.
func testRepo(t *testing.T) (*ChatRepo, *Postgres) {
t.Helper()
uri := os.Getenv("POSTGRES_URI")
if uri == "" {
_ = godotenv.Load(filepath.Join("..", "..", ".env"))
uri = os.Getenv("POSTGRES_URI")
}
if uri == "" {
t.Skip("POSTGRES_URI not set — skipping chat repository integration tests")
}
pg, err := NewPostgres(context.Background(), uri, zap.NewNop())
if err != nil {
t.Skipf("postgres unreachable: %v", err)
}
t.Cleanup(pg.Close)
return NewChatRepo(pg), pg
}
// makeUser inserts a throwaway user and removes it (and anything cascading
// from it) when the test finishes.
func makeUser(t *testing.T, pg *Postgres, label string) string {
t.Helper()
ctx := context.Background()
id := uuid.NewString()
suffix := id[:8]
_, err := pg.Pool.Exec(ctx, `
INSERT INTO users (id, email, password_hash, name, handle)
VALUES ($1, $2, 'x', $3, $4)`,
id, fmt.Sprintf("chattest-%s@example.invalid", suffix), "Chat Test "+label, "chattest_"+suffix)
if err != nil {
t.Fatalf("create test user: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
// dropConversation removes a thread when the test ends; messages, participants
// and attachments cascade with it.
func dropConversation(t *testing.T, pg *Postgres, id string) {
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM conversations WHERE id = $1`, id)
})
}
func send(t *testing.T, r *ChatRepo, convID, sender, body string) *model.Message {
t.Helper()
m, _, err := r.Append(context.Background(), AppendParams{
ConversationID: convID, SenderID: &sender, Kind: model.MessageText, Body: body,
})
if err != nil {
t.Fatalf("append %q: %v", body, err)
}
return m
}
func TestEnsureDM_IsIdempotentFromEitherSide(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
first, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatalf("EnsureDM: %v", err)
}
dropConversation(t, pg, first.ID)
// Same pair, opposite argument order — must resolve to the same thread,
// which is the whole point of the sorted dm_key.
second, err := r.EnsureDM(ctx, b, a)
if err != nil {
t.Fatalf("EnsureDM reversed: %v", err)
}
if second.ID != first.ID {
t.Errorf("reversed EnsureDM made a second thread: %s vs %s", second.ID, first.ID)
}
for _, u := range []string{a, b} {
if _, err := r.Participant(ctx, first.ID, u); err != nil {
t.Errorf("user %s is not a participant: %v", u, err)
}
}
}
func TestParticipant_RejectsOutsiders(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b, outsider := makeUser(t, pg, "A"), makeUser(t, pg, "B"), makeUser(t, pg, "C")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
if _, err := r.Participant(ctx, c.ID, outsider); !errors.Is(err, ErrNotParticipant) {
t.Errorf("outsider got %v, want ErrNotParticipant", err)
}
}
func TestAppend_AssignsGaplessSequence(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
for i := int64(1); i <= 3; i++ {
m := send(t, r, c.ID, a, fmt.Sprintf("msg %d", i))
if m.Seq != i {
t.Errorf("message %d got seq %d", i, m.Seq)
}
}
var lastSeq int64
if err := pg.Pool.QueryRow(ctx, `SELECT last_seq FROM conversations WHERE id=$1`, c.ID).Scan(&lastSeq); err != nil {
t.Fatal(err)
}
if lastSeq != 3 {
t.Errorf("conversation.last_seq = %d, want 3", lastSeq)
}
}
// A retried send must return the original message and must NOT burn a sequence
// number — a gap would break the client's catch-up cursor.
func TestAppend_DeduplicatesRetries(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
clientID := uuid.NewString()
p := AppendParams{ConversationID: c.ID, SenderID: &a, Kind: model.MessageText,
Body: "only once", ClientMsgID: &clientID}
first, created, err := r.Append(ctx, p)
if err != nil || !created {
t.Fatalf("first append: created=%v err=%v", created, err)
}
again, created, err := r.Append(ctx, p)
if err != nil {
t.Fatalf("retry: %v", err)
}
if created {
t.Error("retry reported created=true")
}
if again.ID != first.ID || again.Seq != first.Seq {
t.Errorf("retry returned a different message: %s/%d vs %s/%d", again.ID, again.Seq, first.ID, first.Seq)
}
var count, lastSeq int64
_ = pg.Pool.QueryRow(ctx, `SELECT count(*), max(seq) FROM messages WHERE conversation_id=$1`, c.ID).Scan(&count, &lastSeq)
if count != 1 || lastSeq != 1 {
t.Errorf("after retry: %d messages, last seq %d — want 1 and 1", count, lastSeq)
}
}
func TestMessages_KeysetPaging(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
for i := 1; i <= 5; i++ {
send(t, r, c.ID, a, fmt.Sprintf("m%d", i))
}
page1, err := r.Messages(ctx, c.ID, a, 0, 2)
if err != nil {
t.Fatal(err)
}
if len(page1) != 2 || page1[0].Seq != 5 || page1[1].Seq != 4 {
t.Fatalf("page1 = %v, want seq 5,4", seqsOf(page1))
}
page2, err := r.Messages(ctx, c.ID, a, page1[len(page1)-1].Seq, 2)
if err != nil {
t.Fatal(err)
}
if len(page2) != 2 || page2[0].Seq != 3 || page2[1].Seq != 2 {
t.Fatalf("page2 = %v, want seq 3,2", seqsOf(page2))
}
// entities must come back as a JSON array, never null — the client indexes
// into it without a nil check.
var spans []map[string]any
if err := json.Unmarshal(page1[0].Entities, &spans); err != nil {
t.Errorf("entities not valid JSON: %v (%s)", err, page1[0].Entities)
}
}
// Delete-for-me must hide a message from that user's history AND from their
// inbox preview, while leaving it visible to everyone else.
func TestMessageHides_ArePerUser(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
send(t, r, c.ID, a, "keep")
hidden := send(t, r, c.ID, a, "hide me")
if _, err := pg.Pool.Exec(ctx,
`INSERT INTO message_hides (message_id, user_id) VALUES ($1,$2)`, hidden.ID, a); err != nil {
t.Fatal(err)
}
mine, _ := r.Messages(ctx, c.ID, a, 0, 10)
if len(mine) != 1 || mine[0].Body != "keep" {
t.Errorf("hider still sees %v", bodiesOf(mine))
}
theirs, _ := r.Messages(ctx, c.ID, b, 0, 10)
if len(theirs) != 2 {
t.Errorf("other participant sees %v, want both", bodiesOf(theirs))
}
inbox, err := r.Inbox(ctx, a, []string{model.ConversationDM}, 10)
if err != nil {
t.Fatal(err)
}
row := findConv(inbox, c.ID)
if row == nil || row.LastMessage == nil {
t.Fatal("conversation missing from hider's inbox")
}
if row.LastMessage.Body != "keep" {
t.Errorf("hider's preview = %q, want the un-hidden message", row.LastMessage.Body)
}
}
func TestInbox_UnreadPeerAndPreview(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
send(t, r, c.ID, a, "first")
send(t, r, c.ID, a, "latest")
// Recipient has read nothing.
inbox, err := r.Inbox(ctx, b, []string{model.ConversationDM}, 10)
if err != nil {
t.Fatal(err)
}
row := findConv(inbox, c.ID)
if row == nil {
t.Fatal("thread missing from recipient inbox")
}
if row.Unread != 2 {
t.Errorf("unread = %d, want 2", row.Unread)
}
if row.LastMessage == nil || row.LastMessage.Body != "latest" {
t.Errorf("preview = %+v, want body %q", row.LastMessage, "latest")
}
if row.LastMessage != nil && row.LastMessage.SenderName == "" {
t.Error("preview has no sender name — the inbox line needs it")
}
if row.Peer == nil || row.Peer.ID != a {
t.Errorf("peer = %+v, want user A", row.Peer)
}
// The sender's own cursor advanced on send, so their unread is zero.
senderInbox, _ := r.Inbox(ctx, a, []string{model.ConversationDM}, 10)
if sr := findConv(senderInbox, c.ID); sr == nil || sr.Unread != 0 {
t.Errorf("sender unread = %v, want 0", sr)
}
if err := r.MarkRead(ctx, c.ID, b, 2); err != nil {
t.Fatal(err)
}
after, _ := r.Inbox(ctx, b, []string{model.ConversationDM}, 10)
if row := findConv(after, c.ID); row == nil || row.Unread != 0 {
t.Errorf("after MarkRead unread = %v, want 0", row)
}
}
// Cursors must never move backwards: a replayed or out-of-order receipt would
// otherwise resurrect already-read messages as unread.
func TestCursors_NeverGoBackwards(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
for i := 0; i < 3; i++ {
send(t, r, c.ID, a, "m")
}
if err := r.MarkRead(ctx, c.ID, b, 3); err != nil {
t.Fatal(err)
}
if err := r.MarkRead(ctx, c.ID, b, 1); err != nil { // stale replay
t.Fatal(err)
}
p, err := r.Participant(ctx, c.ID, b)
if err != nil {
t.Fatal(err)
}
if p.LastReadSeq != 3 {
t.Errorf("last_read_seq = %d after a stale replay, want 3", p.LastReadSeq)
}
if p.LastDeliveredSeq != 3 {
t.Errorf("reading should imply delivery: last_delivered_seq = %d, want 3", p.LastDeliveredSeq)
}
}
func TestReceipts_ExcludeCallerAndUseMinimum(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b, cUser := makeUser(t, pg, "A"), makeUser(t, pg, "B"), makeUser(t, pg, "C")
conv, err := r.CreateGroup(ctx, a, "Test group", []string{b, cUser})
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, conv.ID)
for i := 0; i < 3; i++ {
send(t, r, conv.ID, a, "m")
}
if err := r.MarkDelivered(ctx, conv.ID, b, 3); err != nil {
t.Fatal(err)
}
if err := r.MarkRead(ctx, conv.ID, b, 3); err != nil {
t.Fatal(err)
}
// C has received nothing yet.
rc, err := r.Receipts(ctx, conv.ID, a)
if err != nil {
t.Fatal(err)
}
if rc.ReadSeq != 0 || rc.DeliveredSeq != 0 {
t.Errorf("receipts = %+v, want zeros while one member is behind", rc)
}
if err := r.MarkRead(ctx, conv.ID, cUser, 3); err != nil {
t.Fatal(err)
}
rc, _ = r.Receipts(ctx, conv.ID, a)
if rc.ReadSeq != 3 || rc.DeliveredSeq != 3 {
t.Errorf("receipts = %+v, want 3/3 once everyone has read", rc)
}
}
func TestEnsureAIThread_OnePerUserAndSeparateTab(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a := makeUser(t, pg, "A")
first, err := r.EnsureAIThread(ctx, a)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, first.ID)
again, err := r.EnsureAIThread(ctx, a)
if err != nil {
t.Fatal(err)
}
if again.ID != first.ID {
t.Errorf("second call made another AI thread: %s vs %s", again.ID, first.ID)
}
// The tabs are a kind filter: an AI thread must not appear under Chats.
chats, _ := r.Inbox(ctx, a, []string{model.ConversationDM, model.ConversationGroup}, 10)
if findConv(chats, first.ID) != nil {
t.Error("AI thread leaked into the Chats tab")
}
kautilya, _ := r.Inbox(ctx, a, []string{model.ConversationAI}, 10)
if findConv(kautilya, first.ID) == nil {
t.Error("AI thread missing from the Kautilya tab")
}
}
func findConv(items []model.InboxItem, id string) *model.InboxItem {
for i := range items {
if items[i].ID == id {
return &items[i]
}
}
return nil
}
func seqsOf(msgs []model.Message) []int64 {
out := make([]int64, len(msgs))
for i, m := range msgs {
out[i] = m.Seq
}
return out
}
func bodiesOf(msgs []model.Message) []string {
out := make([]string, len(msgs))
for i, m := range msgs {
out[i] = m.Body
}
return out
}
+3 -3
View File
@@ -207,11 +207,11 @@ func (r *CollectionRepo) CollectionsContaining(ctx context.Context, postID, user
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,
)
@@ -227,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)
}
+377
View File
@@ -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
}
+55
View File
@@ -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{})
}
+76
View File
@@ -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,143 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var (
ErrFeedbackCategoryNotFound = errors.New("feedback category not found")
ErrFeedbackCategoryInUse = errors.New("feedback is filed under this category; archive it instead")
ErrFeedbackCategoryExists = errors.New("a category with that name already exists")
)
// FeedbackCategoryRepo stores the /feedback form's categories.
type FeedbackCategoryRepo struct{ pg *Postgres }
func NewFeedbackCategoryRepo(pg *Postgres) *FeedbackCategoryRepo {
return &FeedbackCategoryRepo{pg: pg}
}
// List returns categories in form order. includeArchived is true for the
// admin (which also gets usage counts), false for the public form.
func (r *FeedbackCategoryRepo) List(ctx context.Context, includeArchived bool) ([]model.FeedbackCategory, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT c.key, c.label, c.description, c.sort_order, c.archived_at,
CASE WHEN $1 THEN (SELECT count(*) FROM feedback f WHERE f.category = c.key) ELSE 0 END
FROM feedback_categories c
WHERE $1 OR c.archived_at IS NULL
ORDER BY (c.archived_at IS NOT NULL), c.sort_order, c.label`, includeArchived)
if err != nil {
return nil, fmt.Errorf("list feedback categories: %w", err)
}
defer rows.Close()
out := []model.FeedbackCategory{}
for rows.Next() {
var c model.FeedbackCategory
if err := rows.Scan(&c.Key, &c.Label, &c.Description, &c.SortOrder, &c.ArchivedAt, &c.UsageCount); err != nil {
return nil, fmt.Errorf("scan feedback category: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// IsActive reports whether new feedback may be filed under key.
func (r *FeedbackCategoryRepo) IsActive(ctx context.Context, key string) (bool, error) {
var ok bool
err := r.pg.Pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM feedback_categories WHERE key = $1 AND archived_at IS NULL)`,
key).Scan(&ok)
if err != nil {
return false, fmt.Errorf("check feedback category: %w", err)
}
return ok, nil
}
// KeyTaken reports whether a key is already used, archived or not.
func (r *FeedbackCategoryRepo) KeyTaken(ctx context.Context, key string) (bool, error) {
var ok bool
err := r.pg.Pool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM feedback_categories WHERE key = $1)`, key).Scan(&ok)
return ok, err
}
// Create appends a category at the end of the form.
func (r *FeedbackCategoryRepo) Create(ctx context.Context, key, label, description string) (*model.FeedbackCategory, error) {
c := model.FeedbackCategory{Key: key, Label: label, Description: description}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO feedback_categories (key, label, description, sort_order)
VALUES ($1, $2, $3, COALESCE((SELECT max(sort_order) + 1 FROM feedback_categories WHERE archived_at IS NULL), 0))
RETURNING sort_order`, key, label, description).Scan(&c.SortOrder)
if isUniqueViolation(err, "feedback_categories_pkey") {
return nil, ErrFeedbackCategoryExists
}
if err != nil {
return nil, fmt.Errorf("create feedback category: %w", err)
}
return &c, nil
}
// Update changes a category's wording and archived state. Its key — what
// past feedback points at — is never changed.
func (r *FeedbackCategoryRepo) Update(ctx context.Context, key, label, description string, archived bool) error {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE feedback_categories
SET label = $2, description = $3,
archived_at = CASE WHEN $4 THEN COALESCE(archived_at, now()) ELSE NULL END
WHERE key = $1`, key, label, description, archived)
if err != nil {
return fmt.Errorf("update feedback category: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrFeedbackCategoryNotFound
}
return nil
}
// Reorder sets the form order to the given keys, first to last.
func (r *FeedbackCategoryRepo) Reorder(ctx context.Context, keys []string) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin reorder: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
for i, k := range keys {
if _, err := tx.Exec(ctx,
`UPDATE feedback_categories SET sort_order = $2 WHERE key = $1`, k, i); err != nil {
return fmt.Errorf("reorder feedback category: %w", err)
}
}
return tx.Commit(ctx)
}
// Delete removes a category nothing has been filed under. One that's in use
// can only be archived, so no submission is ever left without a label.
func (r *FeedbackCategoryRepo) Delete(ctx context.Context, key string) error {
var used bool
err := r.pg.Pool.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM feedback WHERE category = $1)`, key).Scan(&used)
if err != nil {
return fmt.Errorf("check category usage: %w", err)
}
if used {
return ErrFeedbackCategoryInUse
}
tag, err := r.pg.Pool.Exec(ctx, `
DELETE FROM feedback_categories c WHERE c.key = $1
AND NOT EXISTS (SELECT 1 FROM feedback f WHERE f.category = c.key)`, key)
if err != nil {
return fmt.Errorf("delete feedback category: %w", err)
}
if tag.RowsAffected() == 0 {
// Gone already, or feedback arrived between the check and the delete.
if ok, _ := r.KeyTaken(ctx, key); ok {
return ErrFeedbackCategoryInUse
}
return ErrFeedbackCategoryNotFound
}
return nil
}
+48 -4
View File
@@ -37,13 +37,14 @@ func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, mes
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,
SELECT f.id, f.user_id, f.category, COALESCE(fc.label, 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
LEFT JOIN feedback_categories fc ON fc.key = f.category
WHERE f.id = $1`, id,
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
).Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
if err != nil {
@@ -85,19 +86,61 @@ func (r *FeedbackRepo) MarkResolved(ctx context.Context, id string) (resolved bo
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 f.id, f.user_id, f.category, COALESCE(fc.label, f.category), f.message, f.contact_email, f.created_at,
f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message
FROM feedback f
LEFT JOIN feedback_categories fc ON fc.key = f.category
WHERE f.user_id = $1
ORDER BY f.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.CategoryLabel, &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.contact_email, f.created_at,
SELECT f.id, f.user_id, f.category, COALESCE(fc.label, 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
LEFT JOIN feedback_categories fc ON fc.key = f.category
ORDER BY f.created_at DESC
LIMIT $1`, limit,
)
@@ -108,7 +151,8 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
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,
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.CategoryLabel, &f.Message, &f.CreatedAt,
&f.HasContactEmail,
&f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName); err != nil {
return nil, fmt.Errorf("scan feedback: %w", err)
+305
View File
@@ -0,0 +1,305 @@
package repository
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"go.uber.org/zap"
)
// likeDeltaPrefix namespaces the per-post pending-like counters in Redis. Each
// like/unlike INCRBY/DECRBYs likes:delta:{postID}; the LikesWorker folds these
// into posts.likes_count every 15 minutes (GETDEL + UPDATE). Reads add the
// still-pending delta on top of likes_count so the displayed total is current.
const likeDeltaPrefix = "likes:delta:"
// LikeRepo handles post-like membership (Postgres) and the write-behind count
// delta (Redis). Membership is written synchronously so viewer state and the
// "liked by" list stay consistent; only the hot counter is batched.
//
// `redis` is optional: when nil (Redis unavailable) likes still work, but the
// count is applied to posts.likes_count directly on each like instead of being
// batched.
type LikeRepo struct {
pg *Postgres
redis *Redis
logger *zap.Logger
}
func NewLikeRepo(pg *Postgres, redis *Redis, logger *zap.Logger) *LikeRepo {
return &LikeRepo{pg: pg, redis: redis, logger: logger}
}
func (r *LikeRepo) deltaKey(postID string) string { return likeDeltaPrefix + postID }
// Like records a membership row. Returns whether it was newly liked (false if
// the user had already liked it — idempotent). On a real change it nudges the
// pending count up.
func (r *LikeRepo) Like(ctx context.Context, postID, userID string) (bool, error) {
tag, err := r.pg.Pool.Exec(ctx,
`INSERT INTO post_likes (post_id, user_id) VALUES ($1, $2)
ON CONFLICT (post_id, user_id) DO NOTHING`,
postID, userID,
)
if err != nil {
return false, fmt.Errorf("insert like: %w", err)
}
if tag.RowsAffected() == 0 {
return false, nil // already liked
}
r.bumpDelta(ctx, postID, 1)
return true, nil
}
// Unlike removes the membership row. Returns whether it actually changed.
func (r *LikeRepo) Unlike(ctx context.Context, postID, userID string) (bool, error) {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM post_likes WHERE post_id = $1 AND user_id = $2`,
postID, userID,
)
if err != nil {
return false, fmt.Errorf("delete like: %w", err)
}
if tag.RowsAffected() == 0 {
return false, nil // wasn't liked
}
r.bumpDelta(ctx, postID, -1)
return true, nil
}
// bumpDelta adjusts the pending count. Prefers the Redis write-behind counter;
// falls back to a direct DB update when Redis is unavailable so the count is
// never lost.
func (r *LikeRepo) bumpDelta(ctx context.Context, postID string, d int64) {
if r.redis != nil {
if err := r.redis.Client.IncrBy(ctx, r.deltaKey(postID), d).Err(); err == nil {
return
}
r.logger.Warn("likes: redis incr failed, applying to DB", zap.String("post", postID))
}
if _, err := r.pg.Pool.Exec(ctx,
`UPDATE posts SET likes_count = GREATEST(0, likes_count + $2) WHERE id = $1`,
postID, d,
); err != nil {
r.logger.Warn("likes: direct count update failed", zap.String("post", postID), zap.Error(err))
}
}
// PostAuthor returns the user id that owns the post (for like notifications).
func (r *LikeRepo) PostAuthor(ctx context.Context, postID string) (string, error) {
var uid string
if err := r.pg.Pool.QueryRow(ctx,
`SELECT user_id FROM posts WHERE id = $1`, postID,
).Scan(&uid); err != nil {
return "", fmt.Errorf("post author: %w", err)
}
return uid, nil
}
// EffectiveCount returns likes_count + the pending Redis delta for one post.
func (r *LikeRepo) EffectiveCount(ctx context.Context, postID string) (int, error) {
var c int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT likes_count FROM posts WHERE id = $1`, postID,
).Scan(&c); err != nil {
return 0, fmt.Errorf("read likes_count: %w", err)
}
if r.redis != nil {
if d, err := r.redis.Client.Get(ctx, r.deltaKey(postID)).Int(); err == nil {
c += d
}
}
if c < 0 {
c = 0
}
return c, nil
}
// Hydrate fills LikesCount (effective) and ViewerHasLiked on the given posts.
// Used by every post read path. viewerID "" leaves ViewerHasLiked false.
func (r *LikeRepo) Hydrate(ctx context.Context, viewerID string, posts []*model.Post) error {
ids := make([]string, 0, len(posts))
for _, p := range posts {
if p != nil {
ids = append(ids, p.ID)
}
}
if len(ids) == 0 {
return nil
}
// Denormalized counts from Postgres.
counts := make(map[string]int, len(ids))
rows, err := r.pg.Pool.Query(ctx,
`SELECT id, likes_count FROM posts WHERE id = ANY($1)`, ids)
if err != nil {
return fmt.Errorf("hydrate like counts: %w", err)
}
for rows.Next() {
var id string
var c int
if err := rows.Scan(&id, &c); err != nil {
rows.Close()
return fmt.Errorf("scan like count: %w", err)
}
counts[id] = c
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
// Pending deltas from Redis (best-effort).
deltas := r.pendingDeltas(ctx, ids)
// Viewer membership.
liked := make(map[string]bool)
if viewerID != "" {
lr, err := r.pg.Pool.Query(ctx,
`SELECT post_id FROM post_likes WHERE user_id = $1 AND post_id = ANY($2)`,
viewerID, ids)
if err != nil {
return fmt.Errorf("hydrate viewer likes: %w", err)
}
for lr.Next() {
var pid string
if err := lr.Scan(&pid); err != nil {
lr.Close()
return fmt.Errorf("scan viewer like: %w", err)
}
liked[pid] = true
}
lr.Close()
if err := lr.Err(); err != nil {
return err
}
}
for _, p := range posts {
if p == nil {
continue
}
c := counts[p.ID] + deltas[p.ID]
if c < 0 {
c = 0
}
p.LikesCount = c
p.ViewerHasLiked = liked[p.ID]
}
return nil
}
// pendingDeltas returns the Redis like-deltas for the given posts (absent →
// zero). Best-effort: Redis errors yield an empty map.
func (r *LikeRepo) pendingDeltas(ctx context.Context, ids []string) map[string]int {
out := make(map[string]int)
if r.redis == nil || len(ids) == 0 {
return out
}
keys := make([]string, len(ids))
for i, id := range ids {
keys[i] = r.deltaKey(id)
}
vals, err := r.redis.Client.MGet(ctx, keys...).Result()
if err != nil {
return out
}
for i, v := range vals {
s, ok := v.(string)
if !ok {
continue
}
if n, err := strconv.Atoi(s); err == nil && n != 0 {
out[ids[i]] = n
}
}
return out
}
// ListLikers returns one page of users who liked the post (newest first),
// optionally filtered by a name/handle search, plus the total matching count.
func (r *LikeRepo) ListLikers(ctx context.Context, postID, q string, limit, offset int) ([]model.UserSummary, int, error) {
like := "%" + q + "%"
var total int
if err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*)
FROM post_likes l JOIN users u ON u.id = l.user_id
WHERE l.post_id = $1
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)`,
postID, q, like,
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count likers: %w", err)
}
if total == 0 {
return []model.UserSummary{}, 0, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT u.handle, u.name, u.avatar_url
FROM post_likes l JOIN users u ON u.id = l.user_id
WHERE l.post_id = $1
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
ORDER BY l.created_at DESC, u.handle ASC
LIMIT $4 OFFSET $5`,
postID, q, like, limit, offset,
)
if err != nil {
return nil, 0, fmt.Errorf("list likers: %w", err)
}
defer rows.Close()
out := make([]model.UserSummary, 0, limit)
for rows.Next() {
var s model.UserSummary
if err := rows.Scan(&s.Handle, &s.Name, &s.AvatarURL); err != nil {
return nil, 0, fmt.Errorf("scan liker: %w", err)
}
out = append(out, s)
}
return out, total, rows.Err()
}
// FlushDeltas drains every pending Redis like-delta into posts.likes_count.
// Called periodically by the LikesWorker. Returns how many posts were updated.
// GETDEL makes each pop atomic; a failed UPDATE restores the delta so it's
// retried next cycle.
func (r *LikeRepo) FlushDeltas(ctx context.Context) (int, error) {
if r.redis == nil {
return 0, nil
}
var cursor uint64
applied := 0
for {
keys, next, err := r.redis.Client.Scan(ctx, cursor, likeDeltaPrefix+"*", 200).Result()
if err != nil {
return applied, fmt.Errorf("scan like deltas: %w", err)
}
for _, key := range keys {
postID := strings.TrimPrefix(key, likeDeltaPrefix)
v, err := r.redis.Client.GetDel(ctx, key).Int()
if err != nil || v == 0 {
continue
}
if _, err := r.pg.Pool.Exec(ctx,
`UPDATE posts SET likes_count = GREATEST(0, likes_count + $2) WHERE id = $1`,
postID, v,
); err != nil {
// Restore so the delta isn't lost; retried next cycle.
r.redis.Client.IncrBy(ctx, key, int64(v))
r.logger.Warn("likes flush: update failed, delta restored",
zap.String("post", postID), zap.Error(err))
continue
}
applied++
}
cursor = next
if cursor == 0 {
break
}
}
return applied, nil
}
+167
View File
@@ -0,0 +1,167 @@
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
-- A suspended account can't be @mentioned into a post.
AND u.banned_at IS NULL
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
}
+333 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
@@ -35,7 +36,8 @@ const postSelectColumns = `
p.reshared_from_post_id,
ou.name AS original_author_name,
ou.handle AS original_author_handle,
ou.avatar_url AS original_author_avatar
ou.avatar_url AS original_author_avatar,
p.is_hidden
`
const postSelectFrom = `
@@ -57,6 +59,7 @@ func scanPost(row interface {
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
&p.ResharedFromPostID,
&p.OriginalAuthorName, &p.OriginalAuthorHandle, &p.OriginalAuthorAvatar,
&p.IsHidden,
)
}
@@ -141,7 +144,7 @@ func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, ima
// 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) {
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1`
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1 AND p.deleted_at IS NULL`
p := &model.Post{}
if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -157,6 +160,46 @@ func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error)
return p, nil
}
// FindByIDs loads many posts at once, keyed by id. Missing and soft-deleted
// ids are simply absent from the map.
//
// Exists so a page of chat messages sharing N posts costs one query instead of
// N — the same reason ImagesFor is batched.
func (r *PostRepo) FindByIDs(ctx context.Context, ids []string) (map[string]*model.Post, error) {
out := make(map[string]*model.Post, len(ids))
if len(ids) == 0 {
return out, nil
}
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = ANY($1) AND p.deleted_at IS NULL`
rows, err := r.pg.Pool.Query(ctx, q, ids)
if err != nil {
return nil, fmt.Errorf("find posts: %w", err)
}
defer rows.Close()
found := make([]string, 0, len(ids))
for rows.Next() {
p := &model.Post{}
if err := scanPost(rows, p); err != nil {
return nil, fmt.Errorf("scan post: %w", err)
}
out[p.ID] = p
found = append(found, p.ID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("find posts: %w", err)
}
imgs, err := r.ImagesFor(ctx, found)
if err != nil {
return nil, err
}
for id, p := range out {
p.Images = imgs[id]
}
return out, nil
}
// 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) {
@@ -170,22 +213,215 @@ func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offse
return r.ListByUser(ctx, userID, limit, offset)
}
// ListMentioningByHandle returns posts that tag the given user (handle),
// newest first — the "Mentions" profile tab. Same shape as the profile feed.
func (r *PostRepo) ListMentioningByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
var userID string
if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return []model.Post{}, 0, nil
}
return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
}
// Hidden posts are dropped from the mentions timeline too (the mentioned
// user is not the author, so there is no owner exception here).
var total int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM posts p JOIN 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)
}
// 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
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)
@@ -202,12 +438,29 @@ func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
return nil
}
// 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,
`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 posts: %w", err)
@@ -215,6 +468,82 @@ func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error)
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
+123
View File
@@ -0,0 +1,123 @@
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.
// SubscribedUserIDs lists everyone with at least one live push subscription.
//
// Used by broadcasts, which fan out to the whole user base: iterating users
// and asking each for subscriptions would query for the (large) majority who
// have never enabled push.
func (r *PushRepo) SubscribedUserIDs(ctx context.Context) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx,
`SELECT DISTINCT user_id FROM push_subscriptions`)
if err != nil {
return nil, fmt.Errorf("subscribed users: %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 subscribed user: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
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
}
+734
View File
@@ -0,0 +1,734 @@
package repository
import (
"context"
"errors"
"fmt"
"sort"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var (
ErrQuizNotFound = errors.New("quiz not found")
ErrQuizNotStarted = errors.New("start the quiz first")
ErrQuizNotCurrent = errors.New("that isn't the current question")
ErrQuizBadOption = errors.New("that option isn't part of this question")
ErrQuizChoiceCount = errors.New("choose one option, or at least one where several apply")
// ErrQuizTargetTaken: that chapter, lecture or conversation already has a
// quiz (enforced by the quizzes_one_per_target index).
ErrQuizTargetTaken = errors.New("only one quiz is allowed per link")
ErrQuizAlreadyAnswer = errors.New("that question is already answered")
ErrQuizNotFinished = errors.New("the quiz isn't finished yet")
)
type QuizRepo struct{ pg *Postgres }
func NewQuizRepo(pg *Postgres) *QuizRepo { return &QuizRepo{pg: pg} }
// QuizInput is a whole quiz as the admin editor submits it.
type QuizInput struct {
Title string
Description string
TargetKind string
TargetPath *string
TargetTitle string
TargetYear *int
Questions []QuestionInput
}
type QuestionInput struct {
Prompt string
Explanation string
Multi bool
Options []OptionInput
}
type OptionInput struct {
Body string
IsCorrect bool
}
const quizColumns = `q.id, q.title, q.description, q.target_kind, q.target_path,
q.target_title, q.target_year, q.published_at, q.created_at, q.updated_at,
(SELECT count(*) FROM quiz_questions qq WHERE qq.quiz_id = q.id)`
func scanQuiz(row pgx.Row, q *model.Quiz, extra ...any) error {
dest := []any{&q.ID, &q.Title, &q.Description, &q.TargetKind, &q.TargetPath,
&q.TargetTitle, &q.TargetYear, &q.PublishedAt, &q.CreatedAt, &q.UpdatedAt, &q.QuestionCount}
return row.Scan(append(dest, extra...)...)
}
// ── Admin ─────────────────────────────────────────────────────────────────
func (r *QuizRepo) ListAdmin(ctx context.Context) ([]model.Quiz, error) {
rows, err := r.pg.Pool.Query(ctx, `SELECT `+quizColumns+`,
(SELECT count(*) FROM quiz_attempts a WHERE a.quiz_id = q.id),
(SELECT count(*) FROM quiz_attempts a WHERE a.quiz_id = q.id AND a.completed_at IS NOT NULL)
FROM quizzes q WHERE q.deleted_at IS NULL
ORDER BY q.updated_at DESC`)
if err != nil {
return nil, fmt.Errorf("list quizzes: %w", err)
}
defer rows.Close()
out := []model.Quiz{}
for rows.Next() {
var q model.Quiz
if err := scanQuiz(rows, &q, &q.AttemptCount, &q.CompletedCount); err != nil {
return nil, fmt.Errorf("scan quiz: %w", err)
}
out = append(out, q)
}
return out, rows.Err()
}
// Get loads a quiz with its questions. revealAnswers controls whether
// IsCorrect is filled in: true for the admin editor and results screens,
// false for anyone mid-attempt.
func (r *QuizRepo) Get(ctx context.Context, id string, revealAnswers bool) (*model.Quiz, error) {
var q model.Quiz
err := scanQuiz(r.pg.Pool.QueryRow(ctx, `SELECT `+quizColumns+`
FROM quizzes q WHERE q.id = $1 AND q.deleted_at IS NULL`, id), &q)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrQuizNotFound
}
if err != nil {
return nil, fmt.Errorf("load quiz: %w", err)
}
if q.Questions, err = r.questions(ctx, id, revealAnswers); err != nil {
return nil, err
}
return &q, nil
}
func (r *QuizRepo) questions(ctx context.Context, quizID string, reveal bool) ([]model.QuizQuestion, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT qq.id, qq.ord, qq.prompt, qq.explanation, qq.multi,
o.id, o.ord, o.body, o.is_correct
FROM quiz_questions qq
JOIN quiz_options o ON o.question_id = qq.id
WHERE qq.quiz_id = $1
ORDER BY qq.ord, o.ord`, quizID)
if err != nil {
return nil, fmt.Errorf("load questions: %w", err)
}
defer rows.Close()
out := []model.QuizQuestion{}
for rows.Next() {
var (
qid, prompt, expl, oid, body string
qord, oord int
multi, correct bool
)
if err := rows.Scan(&qid, &qord, &prompt, &expl, &multi, &oid, &oord, &body, &correct); err != nil {
return nil, fmt.Errorf("scan question: %w", err)
}
if len(out) == 0 || out[len(out)-1].ID != qid {
out = append(out, model.QuizQuestion{ID: qid, Ord: qord, Prompt: prompt, Explanation: expl, Multi: multi})
}
opt := model.QuizOption{ID: oid, Ord: oord, Body: body}
if reveal {
c := correct
opt.IsCorrect = &c
}
last := &out[len(out)-1]
last.Options = append(last.Options, opt)
}
return out, rows.Err()
}
// Create stores a new quiz, unpublished, with its questions.
func (r *QuizRepo) Create(ctx context.Context, in QuizInput, createdBy string) (string, error) {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return "", fmt.Errorf("begin quiz tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
var id string
if err := tx.QueryRow(ctx, `
INSERT INTO quizzes (title, description, target_kind, target_path, target_title, target_year, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, in.TargetYear, createdBy,
).Scan(&id); err != nil {
if isUniqueViolation(err, "quizzes_one_per_target") {
return "", ErrQuizTargetTaken
}
return "", fmt.Errorf("create quiz: %w", err)
}
if err := insertQuestions(ctx, tx, id, in.Questions); err != nil {
return "", err
}
return id, tx.Commit(ctx)
}
// Update rewrites a quiz. replaceQuestions is false once anyone has made an
// attempt: their answers point at specific questions and options, so those
// are frozen and only the title, description and target may change.
func (r *QuizRepo) Update(ctx context.Context, id string, in QuizInput, replaceQuestions bool) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin quiz tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
tag, err := tx.Exec(ctx, `
UPDATE quizzes SET title = $2, description = $3, target_kind = $4,
target_path = $5, target_title = $6, target_year = $7, updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`,
id, in.Title, in.Description, in.TargetKind, in.TargetPath, in.TargetTitle, in.TargetYear)
if err != nil {
if isUniqueViolation(err, "quizzes_one_per_target") {
return ErrQuizTargetTaken
}
return fmt.Errorf("update quiz: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrQuizNotFound
}
if replaceQuestions {
if _, err := tx.Exec(ctx, `DELETE FROM quiz_questions WHERE quiz_id = $1`, id); err != nil {
return fmt.Errorf("clear questions: %w", err)
}
if err := insertQuestions(ctx, tx, id, in.Questions); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func insertQuestions(ctx context.Context, tx pgx.Tx, quizID string, qs []QuestionInput) error {
for i, q := range qs {
var qid string
if err := tx.QueryRow(ctx, `
INSERT INTO quiz_questions (quiz_id, ord, prompt, explanation, multi)
VALUES ($1, $2, $3, $4, $5) RETURNING id`, quizID, i, q.Prompt, q.Explanation, q.Multi,
).Scan(&qid); err != nil {
return fmt.Errorf("insert question: %w", err)
}
for j, o := range q.Options {
if _, err := tx.Exec(ctx, `
INSERT INTO quiz_options (question_id, ord, body, is_correct)
VALUES ($1, $2, $3, $4)`, qid, j, o.Body, o.IsCorrect); err != nil {
return fmt.Errorf("insert option: %w", err)
}
}
}
return nil
}
// QuizOnTarget returns the live quiz (published or draft) already attached to
// a content path, other than exceptID — or "" when the path is free.
func (r *QuizRepo) QuizOnTarget(ctx context.Context, path, exceptID string) (id, title string, err error) {
err = r.pg.Pool.QueryRow(ctx, `
SELECT id, title FROM quizzes
WHERE target_path = $1 AND deleted_at IS NULL
AND ($2 = '' OR id::text <> $2)
LIMIT 1`, path, exceptID).Scan(&id, &title)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", nil
}
if err != nil {
return "", "", fmt.Errorf("quiz on target: %w", err)
}
return id, title, nil
}
func (r *QuizRepo) AttemptCount(ctx context.Context, id string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `SELECT count(*) FROM quiz_attempts WHERE quiz_id = $1`, id).Scan(&n)
return n, err
}
func (r *QuizRepo) SetPublished(ctx context.Context, id string, published bool) error {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE quizzes
SET published_at = CASE WHEN $2 THEN COALESCE(published_at, now()) ELSE NULL END,
updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`, id, published)
if err != nil {
return fmt.Errorf("publish quiz: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrQuizNotFound
}
return nil
}
// Delete hides a quiz everywhere. Soft, so participants' results survive.
func (r *QuizRepo) Delete(ctx context.Context, id string) error {
tag, err := r.pg.Pool.Exec(ctx,
`UPDATE quizzes SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id)
if err != nil {
return fmt.Errorf("delete quiz: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrQuizNotFound
}
return nil
}
// Participants lists everyone who started a quiz, best scores first.
func (r *QuizRepo) Participants(ctx context.Context, quizID string) ([]model.QuizParticipant, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT u.id, u.handle, u.name, u.avatar_url, a.started_at, a.completed_at,
(SELECT count(*) FROM quiz_answers x WHERE x.attempt_id = a.id AND x.answered_at IS NOT NULL),
a.score, a.total,
COALESCE((SELECT sum(x.response_ms) FROM quiz_answers x WHERE x.attempt_id = a.id), 0)
FROM quiz_attempts a
JOIN users u ON u.id = a.user_id
WHERE a.quiz_id = $1
ORDER BY (a.completed_at IS NOT NULL) DESC, a.score DESC, a.started_at`, quizID)
if err != nil {
return nil, fmt.Errorf("quiz participants: %w", err)
}
defer rows.Close()
out := []model.QuizParticipant{}
for rows.Next() {
var p model.QuizParticipant
if err := rows.Scan(&p.UserID, &p.Handle, &p.Name, &p.AvatarURL, &p.StartedAt,
&p.CompletedAt, &p.Answered, &p.Score, &p.Total, &p.TotalMs); err != nil {
return nil, fmt.Errorf("scan participant: %w", err)
}
out = append(out, p)
}
return out, rows.Err()
}
// QuestionStats aggregates every answer per question and option.
func (r *QuizRepo) QuestionStats(ctx context.Context, quizID string) ([]model.QuizQuestionStat, error) {
qs, err := r.questions(ctx, quizID, true)
if err != nil {
return nil, err
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT x.question_id, COALESCE(x.option_ids::text[], '{}'), x.is_correct, x.response_ms
FROM quiz_answers x
JOIN quiz_questions qq ON qq.id = x.question_id
WHERE qq.quiz_id = $1 AND x.answered_at IS NOT NULL`, quizID)
if err != nil {
return nil, fmt.Errorf("quiz answers: %w", err)
}
defer rows.Close()
picks := map[string]int{}
type agg struct {
answered, correct int
times []int
}
per := map[string]*agg{}
for rows.Next() {
var qid string
var oids []string
var correct *bool
var ms *int
if err := rows.Scan(&qid, &oids, &correct, &ms); err != nil {
return nil, fmt.Errorf("scan answer: %w", err)
}
a := per[qid]
if a == nil {
a = &agg{}
per[qid] = a
}
a.answered++
if correct != nil && *correct {
a.correct++
}
if ms != nil {
a.times = append(a.times, *ms)
}
for _, oid := range oids {
picks[oid]++
}
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]model.QuizQuestionStat, 0, len(qs))
for _, q := range qs {
st := model.QuizQuestionStat{ID: q.ID, Ord: q.Ord, Prompt: q.Prompt, Multi: q.Multi}
if a := per[q.ID]; a != nil {
st.Answered, st.Correct = a.answered, a.correct
if n := len(a.times); n > 0 {
sum := 0
for _, t := range a.times {
sum += t
}
st.AvgMs = sum / n
sort.Ints(a.times)
st.MedianMs = a.times[n/2]
}
}
for _, o := range q.Options {
st.Options = append(st.Options, model.QuizOptionStat{QuizOption: o, Picks: picks[o.ID]})
}
out = append(out, st)
}
return out, nil
}
// ResetAttempt deletes one person's attempt — its answers go with it — so the
// quiz is pending for them again and they can take it afresh.
func (r *QuizRepo) ResetAttempt(ctx context.Context, quizID, userID string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM quiz_attempts WHERE quiz_id = $1 AND user_id = $2`, quizID, userID)
if err != nil {
return fmt.Errorf("reset attempt: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrQuizNotStarted
}
return nil
}
// ── Taking a quiz ─────────────────────────────────────────────────────────
// PublishedForPath returns the newest live quiz on a content page, or nil.
func (r *QuizRepo) PublishedForPath(ctx context.Context, path string) (*model.Quiz, error) {
var q model.Quiz
err := scanQuiz(r.pg.Pool.QueryRow(ctx, `SELECT `+quizColumns+`
FROM quizzes q
WHERE q.target_path = $1 AND q.deleted_at IS NULL AND q.published_at IS NOT NULL
ORDER BY q.published_at DESC LIMIT 1`, path), &q)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("quiz for path: %w", err)
}
return &q, nil
}
// ListPublished returns every live quiz, newest first.
func (r *QuizRepo) ListPublished(ctx context.Context) ([]model.Quiz, error) {
rows, err := r.pg.Pool.Query(ctx, `SELECT `+quizColumns+`
FROM quizzes q
WHERE q.deleted_at IS NULL AND q.published_at IS NOT NULL
ORDER BY q.published_at DESC`)
if err != nil {
return nil, fmt.Errorf("list published quizzes: %w", err)
}
defer rows.Close()
out := []model.Quiz{}
for rows.Next() {
var q model.Quiz
if err := scanQuiz(rows, &q); err != nil {
return nil, fmt.Errorf("scan quiz: %w", err)
}
out = append(out, q)
}
return out, rows.Err()
}
// Statuses returns the caller's progress on each of the given quizzes.
// Quizzes they haven't touched are simply absent.
func (r *QuizRepo) Statuses(ctx context.Context, userID string, quizIDs []string) (map[string]model.QuizStatus, error) {
out := map[string]model.QuizStatus{}
if userID == "" || len(quizIDs) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT a.quiz_id, a.completed_at IS NOT NULL, a.score, a.total,
(SELECT count(*) FROM quiz_answers x WHERE x.attempt_id = a.id AND x.answered_at IS NOT NULL)
FROM quiz_attempts a
WHERE a.user_id = $1 AND a.quiz_id = ANY($2)`, userID, quizIDs)
if err != nil {
return nil, fmt.Errorf("quiz statuses: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
var done bool
var score, total, answered int
if err := rows.Scan(&id, &done, &score, &total, &answered); err != nil {
return nil, fmt.Errorf("scan status: %w", err)
}
st := model.QuizStatus{State: model.QuizInProgress, Answered: answered, Total: total}
if done {
st.State = model.QuizCompleted
s := score
st.Score = &s
}
out[id] = st
}
return out, rows.Err()
}
type attemptRow struct {
ID string
StartedAt time.Time
CompletedAt *time.Time
Score int
Total int
}
func (r *QuizRepo) attempt(ctx context.Context, quizID, userID string) (*attemptRow, error) {
var a attemptRow
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, started_at, completed_at, score, total
FROM quiz_attempts WHERE quiz_id = $1 AND user_id = $2`, quizID, userID,
).Scan(&a.ID, &a.StartedAt, &a.CompletedAt, &a.Score, &a.Total)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrQuizNotStarted
}
if err != nil {
return nil, fmt.Errorf("load attempt: %w", err)
}
return &a, nil
}
// Start opens the caller's attempt. Idempotent: starting again resumes.
func (r *QuizRepo) Start(ctx context.Context, quizID, userID string, total int) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO quiz_attempts (quiz_id, user_id, total) VALUES ($1, $2, $3)
ON CONFLICT (quiz_id, user_id) DO NOTHING`, quizID, userID, total)
if err != nil {
return fmt.Errorf("start attempt: %w", err)
}
return nil
}
// Next returns the first unanswered question and stamps the moment it was
// served. The stamp is set ONCE: fetching the same question again — a reload,
// a second tab — doesn't restart its clock.
func (r *QuizRepo) Next(ctx context.Context, quizID, userID string) (*model.QuizPlay, error) {
a, err := r.attempt(ctx, quizID, userID)
if err != nil {
return nil, err
}
play := &model.QuizPlay{QuizID: quizID, Total: a.Total}
if err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM quiz_answers WHERE attempt_id = $1 AND answered_at IS NOT NULL`,
a.ID).Scan(&play.Answered); err != nil {
return nil, fmt.Errorf("count answered: %w", err)
}
if a.CompletedAt != nil {
play.Done = true
return play, nil
}
var qid string
err = r.pg.Pool.QueryRow(ctx, `
SELECT qq.id FROM quiz_questions qq
WHERE qq.quiz_id = $1
AND NOT EXISTS (SELECT 1 FROM quiz_answers x
WHERE x.attempt_id = $2 AND x.question_id = qq.id
AND x.answered_at IS NOT NULL)
ORDER BY qq.ord LIMIT 1`, quizID, a.ID).Scan(&qid)
if errors.Is(err, pgx.ErrNoRows) {
// Every question answered but not yet marked complete — finish it.
if err := r.complete(ctx, a.ID); err != nil {
return nil, err
}
play.Done = true
return play, nil
}
if err != nil {
return nil, fmt.Errorf("next question: %w", err)
}
if _, err := r.pg.Pool.Exec(ctx, `
INSERT INTO quiz_answers (attempt_id, question_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING`, a.ID, qid); err != nil {
return nil, fmt.Errorf("stamp question: %w", err)
}
qs, err := r.questions(ctx, quizID, false)
if err != nil {
return nil, err
}
for i := range qs {
if qs[i].ID == qid {
play.Question = &qs[i]
// The explanation discusses the answer; it belongs on the
// results screen, not beside the question being answered.
play.Question.Explanation = ""
}
}
return play, nil
}
// Answer records the caller's choice on the CURRENT question: one option, or
// for a multi question every option they think applies.
//
// The question must have been served (Next stamped it), which enforces order
// and gives the server its own start time. Response time is shown→answered,
// both server clocks. An answer can't be changed once given. A multi answer
// is correct only when the chosen set equals the correct set exactly.
func (r *QuizRepo) Answer(ctx context.Context, quizID, userID, questionID string, optionIDs []string) error {
a, err := r.attempt(ctx, quizID, userID)
if err != nil {
return err
}
if a.CompletedAt != nil {
return ErrQuizAlreadyAnswer
}
chosen := map[string]bool{}
for _, id := range optionIDs {
chosen[id] = true
}
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin answer tx: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
rows, err := tx.Query(ctx, `
SELECT qq.multi, o.id, o.is_correct FROM quiz_options o
JOIN quiz_questions qq ON qq.id = o.question_id
WHERE o.question_id = $1 AND qq.quiz_id = $2`, questionID, quizID)
if err != nil {
return fmt.Errorf("check options: %w", err)
}
var multi bool
known, wrongPick, missed := 0, false, false
for rows.Next() {
var oid string
var isCorrect bool
if err := rows.Scan(&multi, &oid, &isCorrect); err != nil {
rows.Close()
return fmt.Errorf("scan option: %w", err)
}
if chosen[oid] {
known++
wrongPick = wrongPick || !isCorrect
} else if isCorrect {
missed = true
}
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("check options: %w", err)
}
if known != len(chosen) {
// Something chosen isn't an option of this question (or the question
// isn't part of this quiz at all).
return ErrQuizBadOption
}
if len(chosen) == 0 || (!multi && len(chosen) != 1) {
return ErrQuizChoiceCount
}
correct := !wrongPick && !missed
ids := make([]string, 0, len(chosen))
for _, id := range optionIDs {
if chosen[id] {
ids = append(ids, id)
delete(chosen, id) // keep the first of any duplicate
}
}
var answeredAt *time.Time
err = tx.QueryRow(ctx, `
SELECT answered_at FROM quiz_answers
WHERE attempt_id = $1 AND question_id = $2 FOR UPDATE`, a.ID, questionID).Scan(&answeredAt)
if errors.Is(err, pgx.ErrNoRows) {
return ErrQuizNotCurrent
}
if err != nil {
return fmt.Errorf("load answer row: %w", err)
}
if answeredAt != nil {
return ErrQuizAlreadyAnswer
}
if _, err := tx.Exec(ctx, `
UPDATE quiz_answers
SET option_ids = $3::uuid[], answered_at = now(), is_correct = $4,
response_ms = GREATEST(0, (EXTRACT(EPOCH FROM (now() - shown_at)) * 1000)::int)
WHERE attempt_id = $1 AND question_id = $2`,
a.ID, questionID, ids, correct); err != nil {
return fmt.Errorf("record answer: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit answer: %w", err)
}
// Finish the attempt once nothing is left unanswered.
var remaining int
if err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM quiz_questions qq
WHERE qq.quiz_id = $1 AND NOT EXISTS (
SELECT 1 FROM quiz_answers x WHERE x.attempt_id = $2
AND x.question_id = qq.id AND x.answered_at IS NOT NULL)`,
quizID, a.ID).Scan(&remaining); err != nil {
return fmt.Errorf("count remaining: %w", err)
}
if remaining == 0 {
return r.complete(ctx, a.ID)
}
return nil
}
func (r *QuizRepo) complete(ctx context.Context, attemptID string) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE quiz_attempts SET completed_at = now(),
score = (SELECT count(*) FROM quiz_answers WHERE attempt_id = $1 AND is_correct)
WHERE id = $1 AND completed_at IS NULL`, attemptID)
if err != nil {
return fmt.Errorf("complete attempt: %w", err)
}
return nil
}
// Result returns an attempt with the answers revealed.
//
// allowIncomplete is false on the user's path, which refuses until the attempt
// is finished — otherwise a result would be a way to peek at the answers. The
// admin passes true to inspect an attempt still in progress.
func (r *QuizRepo) Result(ctx context.Context, quizID, userID string, allowIncomplete bool) (*model.QuizResult, error) {
a, err := r.attempt(ctx, quizID, userID)
if err != nil {
return nil, err
}
if a.CompletedAt == nil && !allowIncomplete {
return nil, ErrQuizNotFinished
}
quiz, err := r.Get(ctx, quizID, true)
if err != nil {
return nil, err
}
res := &model.QuizResult{
Score: a.Score, Total: a.Total, StartedAt: a.StartedAt, CompletedAt: a.CompletedAt,
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT question_id, COALESCE(option_ids::text[], '{}'), COALESCE(is_correct, false), response_ms
FROM quiz_answers WHERE attempt_id = $1`, a.ID)
if err != nil {
return nil, fmt.Errorf("load result answers: %w", err)
}
defer rows.Close()
type ans struct {
options []string
correct bool
ms *int
}
got := map[string]ans{}
for rows.Next() {
var qid string
var x ans
if err := rows.Scan(&qid, &x.options, &x.correct, &x.ms); err != nil {
return nil, fmt.Errorf("scan result answer: %w", err)
}
got[qid] = x
}
if err := rows.Err(); err != nil {
return nil, err
}
for _, q := range quiz.Questions {
item := model.QuizResultItem{QuizQuestion: q, ChosenOptionIDs: []string{}}
if x, ok := got[q.ID]; ok {
item.ChosenOptionIDs, item.IsCorrect, item.ResponseMs = x.options, x.correct, x.ms
if x.ms != nil {
res.TotalMs += *x.ms
}
}
res.Items = append(res.Items, item)
}
quiz.Questions = nil
res.Quiz = *quiz
return res, nil
}
+122 -3
View File
@@ -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
}
+129
View File
@@ -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
}
+81
View File
@@ -0,0 +1,81 @@
package repository
import (
"context"
"fmt"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// SadhnaRepo wraps Postgres access for sadhna_logs — the chanting half of
// a day's sādhana. The posting half lives in the posts table and is read
// via PostRepo.PostedDates, so this repo stays small.
type SadhnaRepo struct{ pg *Postgres }
func NewSadhnaRepo(pg *Postgres) *SadhnaRepo { return &SadhnaRepo{pg: pg} }
// UserTimezone returns the user's stored IANA timezone, or "" if unset — the
// caller falls back to a default. Kept here so the sādhana day boundary can
// be the user's local midnight.
func (r *SadhnaRepo) UserTimezone(ctx context.Context, userID string) (string, error) {
var tz string
err := r.pg.Pool.QueryRow(ctx,
`SELECT COALESCE(NULLIF(timezone, ''), '') FROM users WHERE id = $1`, userID,
).Scan(&tz)
if err != nil {
return "", err
}
return tz, nil
}
// UpsertToday records (or updates) the given day's chanting progress for
// the user. The caller passes the UTC calendar date explicitly (rather
// than relying on the DB's CURRENT_DATE) so day bucketing stays identical
// to PostedDates' `AT TIME ZONE 'UTC'`. chantDone is decided by the caller
// — rounds_completed >= the user's current target — and frozen into the
// row so later target changes don't rewrite past days. Returns the row.
func (r *SadhnaRepo) UpsertToday(ctx context.Context, userID string, logDate time.Time, rounds int, chantDone bool) (*model.SadhnaLog, error) {
var l model.SadhnaLog
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO sadhna_logs (user_id, log_date, rounds_completed, chant_done)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, log_date) DO UPDATE
SET rounds_completed = EXCLUDED.rounds_completed,
chant_done = EXCLUDED.chant_done,
updated_at = now()
RETURNING id, user_id, log_date, rounds_completed, chant_done, created_at, updated_at`,
userID, logDate, rounds, chantDone,
).Scan(&l.ID, &l.UserID, &l.LogDate, &l.RoundsCompleted, &l.ChantDone, &l.CreatedAt, &l.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("upsert sadhna log: %w", err)
}
return &l, nil
}
// ListSince returns the user's sadhna logs on or after `since`, newest
// first. The window bounds the streak/growth computation; a few hundred
// rows at most.
func (r *SadhnaRepo) ListSince(ctx context.Context, userID string, since time.Time) ([]model.SadhnaLog, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, user_id, log_date, rounds_completed, chant_done, created_at, updated_at
FROM sadhna_logs
WHERE user_id = $1 AND log_date >= $2
ORDER BY log_date DESC`,
userID, since,
)
if err != nil {
return nil, fmt.Errorf("list sadhna logs: %w", err)
}
defer rows.Close()
var out []model.SadhnaLog
for rows.Next() {
var l model.SadhnaLog
if err := rows.Scan(&l.ID, &l.UserID, &l.LogDate, &l.RoundsCompleted, &l.ChantDone, &l.CreatedAt, &l.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan sadhna log: %w", err)
}
out = append(out, l)
}
return out, rows.Err()
}
+324
View File
@@ -0,0 +1,324 @@
package repository
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// ErrRewardNotFound is an unknown or retired reward key.
var ErrRewardNotFound = errors.New("reward not found")
// 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, g.source, g.celebrated_at IS NOT NULL,
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
-- NULLS LAST so gifts, which have no milestone, sort among the rest
-- by date rather than jumping to the top.
ORDER BY g.granted_at DESC, g.milestone DESC NULLS LAST`,
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.Source, &g.Opened,
&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
}
// CreateCustom adds a reward to the catalog from the admin panel.
//
// Custom entries survive re-seeding: Seed upserts only the keys in its
// embedded JSON, and these keys are namespaced away from those.
func (r *RewardRepo) CreateCustom(ctx context.Context, key, name, meaning, imageURL string) (*model.SadhnaReward, error) {
var w model.SadhnaReward
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO sadhna_rewards (key, name, meaning, image_url, active, sort)
-- Sorted after the seeded catalog so custom gifts don't reshuffle it.
VALUES ($1, $2, $3, $4, true, 1000)
RETURNING id, key, name, meaning, image_url, active, sort`,
key, name, meaning, imageURL,
).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort)
if err != nil {
return nil, fmt.Errorf("create custom reward: %w", err)
}
return &w, nil
}
// DeleteCustom removes an admin-made reward from the catalog.
//
// Both sadhna_reward_grants.reward_id and saved_stickers.reward_id are
// ON DELETE CASCADE, so this also takes it out of every devotee's gallery and
// sticker vault. Stickers already SENT in a chat are unaffected: those copy
// the artwork onto the message rather than referencing the catalog, so history
// stays intact.
//
// Restricted to custom keys. A seeded reward would come straight back on the
// next Seed run, and deleting one would rip a streak reward out of the
// galleries of everyone who earned it.
func (r *RewardRepo) DeleteCustom(ctx context.Context, key string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM sadhna_rewards WHERE key = $1 AND key LIKE 'custom\_%'`, key)
if err != nil {
return fmt.Errorf("delete custom reward: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRewardNotFound
}
return nil
}
// ByKey looks up one reward from the catalog.
func (r *RewardRepo) ByKey(ctx context.Context, key string) (*model.SadhnaReward, error) {
var w model.SadhnaReward
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, key, name, meaning, image_url, active, sort
FROM sadhna_rewards WHERE key = $1 AND active`, key,
).Scan(&w.ID, &w.Key, &w.Name, &w.Meaning, &w.ImageURL, &w.Active, &w.Sort)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrRewardNotFound
}
if err != nil {
return nil, fmt.Errorf("reward by key: %w", err)
}
return &w, nil
}
// GrantGiftToEveryone hands one reward to every account, unopened.
//
// Idempotent by the partial unique index: re-running adds it only to people
// who don't have it, so a mis-click can't create duplicate tiles. Returns how
// many accounts it reached.
func (r *RewardRepo) GrantGiftToEveryone(ctx context.Context, rewardID string) (int64, error) {
tag, err := r.pg.Pool.Exec(ctx, `
INSERT INTO sadhna_reward_grants (user_id, reward_id, source)
SELECT u.id, $1, 'gift'
FROM users u
WHERE u.banned_at IS NULL
ON CONFLICT DO NOTHING`, rewardID)
if err != nil {
return 0, fmt.Errorf("grant gift: %w", err)
}
return tag.RowsAffected(), nil
}
// OpenGift marks one gift as seen, which is what un-blurs its tile.
//
// Scoped to a single reward rather than clearing everything pending: a devotee
// with two gifts waiting should open them one at a time.
func (r *RewardRepo) OpenGift(ctx context.Context, userID, rewardKey string) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE sadhna_reward_grants g
SET celebrated_at = now()
FROM sadhna_rewards w
WHERE w.id = g.reward_id
AND g.user_id = $1 AND w.key = $2
AND g.source = 'gift' AND g.celebrated_at IS NULL`, userID, rewardKey)
if err != nil {
return fmt.Errorf("open gift: %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.
//
// STREAK grants only. An admin gift is also uncelebrated until opened, but it
// waits as a blurred tile in the gallery rather than ambushing the devotee
// with confetti the moment they load the page.
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 AND g.source = 'streak'
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,
)
g.Source = "streak"
if err != nil {
return nil, nil // no rows / nothing pending
}
return &g, nil
}
// MarkCelebrated clears all of the user's pending celebrations.
func (r *RewardRepo) MarkCelebrated(ctx context.Context, userID string) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE sadhna_reward_grants SET celebrated_at = $1
WHERE user_id = $2 AND celebrated_at IS NULL`,
time.Now().UTC(), userID,
)
if err != nil {
return fmt.Errorf("mark celebrated: %w", err)
}
return nil
}
+110
View File
@@ -0,0 +1,110 @@
{
"_note": "Sādhana streak rewards catalog. `name` values are evocative suggestions — edit freely. Write each `meaning` (symbolic significance) yourself; blank meanings are fine until you fill them. `key` is the stable slug (R2 object key + DB key); don't rename unless you also rename the source file. `file` is the source image in this folder. `image_url` is filled automatically by the R2 upload step. `active:false` removes a reward from the gifting pool.",
"r2_prefix": "sadhna-rewards",
"rewards": [
{
"key": "conchshell",
"file": "conchshell.png",
"name": "Śaṅkh — The Cosmic Conch",
"meaning": "The Divine Conchshell held by Lord Viśnu in one of his hand symbolising purity, auspiciousness, and the primordial sound 'Om'",
"active": true,
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/conchshell.png"
},
{
"key": "white-lotus",
"file": "white-lotus.png",
"name": "Sahasra-dala — Lotus of Pure Being",
"meaning": "The symbol of absolute purity of mind, heart, and soul, untouched by external negativity.",
"active": true,
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/white-lotus.png"
},
{
"key": "pink-lotus",
"file": "pink-lotus.png",
"name": "Hṛdaya-padma — Lotus of the Surrendered Heart",
"meaning": "They symbol of Śri Herself, pink lotus symbolises the loving devotion towards the Lord",
"active": true,
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/pink-lotus.png"
},
{
"key": "golden-lotus",
"file": "golden-lotus.png",
"name": "Hiraṇmaya Padma — The Golden Lotus of Transcendence",
"meaning": "The River Ganges flowing from the heavenly planets is full of these golden lotus flowers, and the residents of those planets eat the stems of the those lotuses. Thus they are very beautiful, more so than the inhabitants of any other planet.",
"active": true,
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/golden-lotus.png"
},
{
"key": "peacock-feather",
"file": "peacock-feather.png",
"name": "Mayūra-piñcha — Crown of the Cosmic Cowherd",
"meaning": "Bedeckded by Lord Kṛṣṇa Himself; the peacock feather is the symbol of absorbing negativity and transforming it into something divine.",
"active": true,
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/peacock-feather.png"
},
{
"key": "mridangam",
"file": "mridangam.png",
"name": "Mṛdaṅga — Drum of the Golden Avatāra",
"meaning": "Srila Prabhupada's favorite musical instrument. The Heart of the Sankirtan movement.",
"active": true,
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/mridangam.png"
},
{
"key": "manjira",
"file": "manjira.png",
"name": "Karatāla — Cymbals of the Eternal Name",
"meaning": "The hand cymbals that ring through every kīrtana. Their clear, bright sound keeps the rhythm of the Holy Name and gathers many voices into one — the heartbeat of saṅkīrtana alongside the mṛdaṅga.",
"active": true,
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/manjira.png"
},
{
"key": "kadamba",
"file": "kadamba.png",
"name": "Kadamba — Blossom of Eternal Vṛndāvana",
"meaning": "A flower so dear to Kṛṣṇa that when Kaliya serpent came, He jumped off from a Kadamba tree. The tree still exists in Kaliya Ghat in Vrndavan.",
"active": true,
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/kadamba.png"
},
{
"key": "shalagram",
"file": "shalagram.png",
"name": "Śālagrāma — The Cosmos in a Stone",
"meaning": "A self-manifested sacred stone from the Gaṇḍakī River, worshiped as the direct, non-different form of Lord Viṣṇu Himself. The Supreme Lord mercifully appears in stone so that the devotee may bathe, dress, and serve Him with their own hands.",
"active": true,
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/shalagram.png"
},
{
"key": "srila-prabhupada-footprints",
"file": "srila-prabhupada-footprints.png",
"name": "Caraṇa-kamala — The Ācārya's Lotus Footprints",
"meaning": "The lotus feet of the spiritual master are the boat that carries the soul across the ocean of birth and death. To hold his footprints upon one's head — to follow faithfully in his steps — is both the beginning and the perfection of devotional life.",
"active": true,
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/srila-prabhupada-footprints.png"
},
{
"key": "kuliya-prasad",
"file": "kuliya-prasad.png",
"name": "Kuliyā Prasadi — Sri Radharaman Lal's favorite",
"meaning": "Butter sweet in a tiny clay pot. Loved by and offered daily to Sri Radharaman Lal",
"active": true,
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/kuliya-prasad.png"
},
{
"key": "peda-prasadam",
"file": "peda-prasadam.png",
"name": "Amṛta-pedā — The Nectar Sweet",
"meaning": "Vrndavana's most famous Peda prasadam. Haribol!",
"active": true,
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/peda-prasadam.png"
},
{
"key": "rasgulla",
"file": "rasgulla.png",
"name": "Rasa-gullā — Spheres of Divine Nectar",
"meaning": "The only sweet Lord Jagannatha gives to pacify Goddess Lakśmi every year during Rath Yatra.",
"active": true,
"image_url": "https://s3.prabhupadasays.com/sadhna-rewards/rasgulla.png"
}
]
}
+210
View File
@@ -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
}
}
+270 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
@@ -93,6 +94,73 @@ func (r *FollowRepo) Status(ctx context.Context, followerID, followingID string)
}
// CountFollowers counts accepted edges pointing at userID.
// ListFollowers returns the accepted followers of userID (users who follow
// them), newest first, optionally filtered by a name/handle search `q`.
// Returns the page plus the total matching count (for pagination).
func (r *FollowRepo) ListFollowers(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
return r.listRelations(ctx, "f.following_id = $1", "f.follower_id", "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, `
@@ -174,6 +242,35 @@ func (r *BlockRepo) Unblock(ctx context.Context, blockerID, blockedID string) er
// BlockState reports, in one round-trip, whether a blocked b and whether
// b blocked a.
// BlockedWith returns the subset of others that viewerID is blocked with in
// EITHER direction — the set whose content must be hidden from them.
//
// The batched counterpart to BlockState, for resolving many authors at once.
func (r *BlockRepo) BlockedWith(ctx context.Context, viewerID string, others []string) (map[string]bool, error) {
out := make(map[string]bool, len(others))
if viewerID == "" || len(others) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT CASE WHEN blocker_id = $1 THEN blocked_id ELSE blocker_id END
FROM user_blocks
WHERE (blocker_id = $1 AND blocked_id = ANY($2))
OR (blocked_id = $1 AND blocker_id = ANY($2))`,
viewerID, others)
if err != nil {
return nil, fmt.Errorf("blocked with: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan blocked with: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) {
err = r.pg.Pool.QueryRow(ctx, `
SELECT
@@ -254,16 +351,94 @@ func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *s
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).
// 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.created_at, n.read_at,
u.name, u.handle, u.avatar_url
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,
)
@@ -274,8 +449,10 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
out := []model.Notification{}
for rows.Next() {
var n model.Notification
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt,
&n.ActorName, &n.ActorHandle, &n.ActorAvatar); err != nil {
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)
@@ -283,11 +460,96 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
return out, rows.Err()
}
// CountUnread returns how many of a recipient's notifications are unread.
// 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 WHERE user_id = $1 AND read_at IS NULL`, userID,
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)
+241
View File
@@ -0,0 +1,241 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var (
ErrNotStaff = errors.New("not a staff member")
ErrRoleNotFound = errors.New("role not found")
ErrRoleKeyTaken = errors.New("a role with that name already exists")
ErrRoleInUse = errors.New("role is still assigned to staff")
)
// SuperadminKey is the seeded role that holds the wildcard.
const SuperadminKey = "superadmin"
type StaffRepo struct{ pg *Postgres }
func NewStaffRepo(pg *Postgres) *StaffRepo { return &StaffRepo{pg: pg} }
// Access loads the caller's role and permissions. ErrNotStaff when the account
// holds no admin role at all.
func (r *StaffRepo) Access(ctx context.Context, userID string) (*model.StaffAccess, error) {
var a model.StaffAccess
err := r.pg.Pool.QueryRow(ctx, `
SELECT ro.key, ro.name, ro.permissions, u.admin_blocked_at IS NOT NULL
FROM users u
JOIN admin_roles ro ON ro.id = u.admin_role_id
WHERE u.id = $1 AND u.banned_at IS NULL`, userID,
).Scan(&a.RoleKey, &a.RoleName, &a.Permissions, &a.Blocked)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotStaff
}
if err != nil {
return nil, fmt.Errorf("staff access: %w", err)
}
return &a, nil
}
const staffSelect = `
SELECT u.id, u.handle, u.name, u.email, u.avatar_url,
ro.id, ro.key, ro.name, u.admin_blocked_at
FROM users u
JOIN admin_roles ro ON ro.id = u.admin_role_id`
func scanStaff(row pgx.Row, m *model.StaffMember) error {
return row.Scan(&m.UserID, &m.Handle, &m.Name, &m.Email, &m.AvatarURL,
&m.RoleID, &m.RoleKey, &m.RoleName, &m.BlockedAt)
}
// ListStaff returns every account holding a role, superadmins first.
func (r *StaffRepo) ListStaff(ctx context.Context) ([]model.StaffMember, error) {
rows, err := r.pg.Pool.Query(ctx, staffSelect+`
ORDER BY (ro.key = 'superadmin') DESC, ro.name, u.name`)
if err != nil {
return nil, fmt.Errorf("list staff: %w", err)
}
defer rows.Close()
out := []model.StaffMember{}
for rows.Next() {
var m model.StaffMember
if err := scanStaff(rows, &m); err != nil {
return nil, fmt.Errorf("scan staff: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// StaffMember loads one staff member. ErrNotStaff if they hold no role.
func (r *StaffRepo) StaffMember(ctx context.Context, userID string) (*model.StaffMember, error) {
var m model.StaffMember
err := scanStaff(r.pg.Pool.QueryRow(ctx, staffSelect+` WHERE u.id = $1`, userID), &m)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotStaff
}
if err != nil {
return nil, fmt.Errorf("staff member: %w", err)
}
return &m, nil
}
// SetRole grants or changes a role. A nil roleID revokes staff access and
// clears any block, so a later re-grant starts clean.
func (r *StaffRepo) SetRole(ctx context.Context, userID string, roleID *string) error {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE users
SET admin_role_id = $2,
admin_blocked_at = CASE WHEN $2::uuid IS NULL THEN NULL ELSE admin_blocked_at END,
updated_at = now()
WHERE id = $1`, userID, roleID)
if err != nil {
return fmt.Errorf("set staff role: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrUserGone
}
return nil
}
// SetBlocked suspends or restores a staff member's panel access.
func (r *StaffRepo) SetBlocked(ctx context.Context, userID string, blocked bool) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE users
SET admin_blocked_at = CASE WHEN $2 THEN now() ELSE NULL END
WHERE id = $1 AND admin_role_id IS NOT NULL`, userID, blocked)
if err != nil {
return fmt.Errorf("set staff blocked: %w", err)
}
return nil
}
// ActiveSuperadmins counts superadmins who aren't blocked — the floor that
// stops the last one from being demoted, revoked or blocked.
func (r *StaffRepo) ActiveSuperadmins(ctx context.Context) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM users u
JOIN admin_roles ro ON ro.id = u.admin_role_id
WHERE ro.key = $1 AND u.admin_blocked_at IS NULL AND u.banned_at IS NULL`,
SuperadminKey).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count superadmins: %w", err)
}
return n, nil
}
// UserIDByHandle resolves a handle for granting a role.
func (r *StaffRepo) UserIDByHandle(ctx context.Context, handle string) (string, error) {
var id string
err := r.pg.Pool.QueryRow(ctx,
`SELECT id FROM users WHERE handle = $1 AND banned_at IS NULL`, handle).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrUserGone
}
if err != nil {
return "", fmt.Errorf("resolve handle: %w", err)
}
return id, nil
}
// ── Roles ─────────────────────────────────────────────────────────────────
func (r *StaffRepo) ListRoles(ctx context.Context) ([]model.AdminRole, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT ro.id, ro.key, ro.name, ro.description, ro.is_system,
ro.permissions, ro.created_at,
(SELECT count(*) FROM users u WHERE u.admin_role_id = ro.id)
FROM admin_roles ro
ORDER BY ro.is_system DESC,
array_position(ARRAY['superadmin','admin','moderator'], ro.key::text),
ro.name`)
if err != nil {
return nil, fmt.Errorf("list roles: %w", err)
}
defer rows.Close()
out := []model.AdminRole{}
for rows.Next() {
var ro model.AdminRole
if err := rows.Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description,
&ro.IsSystem, &ro.Permissions, &ro.CreatedAt, &ro.MemberCount); err != nil {
return nil, fmt.Errorf("scan role: %w", err)
}
out = append(out, ro)
}
return out, rows.Err()
}
func (r *StaffRepo) Role(ctx context.Context, id string) (*model.AdminRole, error) {
var ro model.AdminRole
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, key, name, description, is_system, permissions, created_at
FROM admin_roles WHERE id = $1`, id,
).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrRoleNotFound
}
if err != nil {
return nil, fmt.Errorf("load role: %w", err)
}
return &ro, nil
}
func (r *StaffRepo) CreateRole(ctx context.Context, key, name, description string, perms []string) (*model.AdminRole, error) {
var ro model.AdminRole
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO admin_roles (key, name, description, permissions)
VALUES ($1, $2, $3, $4)
RETURNING id, key, name, description, is_system, permissions, created_at`,
key, name, description, perms,
).Scan(&ro.ID, &ro.Key, &ro.Name, &ro.Description, &ro.IsSystem, &ro.Permissions, &ro.CreatedAt)
if err != nil {
if isUniqueViolation(err, "admin_roles_key_key") {
return nil, ErrRoleKeyTaken
}
return nil, fmt.Errorf("create role: %w", err)
}
return &ro, nil
}
// UpdateRole edits a CUSTOM role; system roles are filtered out in SQL so a
// bug higher up still can't rewrite superadmin.
func (r *StaffRepo) UpdateRole(ctx context.Context, id, name, description string, perms []string) error {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE admin_roles
SET name = $2, description = $3, permissions = $4, updated_at = now()
WHERE id = $1 AND NOT is_system`, id, name, description, perms)
if err != nil {
return fmt.Errorf("update role: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRoleNotFound
}
return nil
}
// DeleteRole removes an unused CUSTOM role.
func (r *StaffRepo) DeleteRole(ctx context.Context, id string) error {
var members int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM users WHERE admin_role_id = $1`, id).Scan(&members); err != nil {
return fmt.Errorf("count role members: %w", err)
}
if members > 0 {
return ErrRoleInUse
}
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM admin_roles WHERE id = $1 AND NOT is_system`, id)
if err != nil {
return fmt.Errorf("delete role: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrRoleNotFound
}
return nil
}
+15 -14
View File
@@ -17,29 +17,30 @@ func NewStatsRepo(pg *Postgres) *StatsRepo { return &StatsRepo{pg: pg} }
// StatsSnapshot bundles totals, last-7-days counts, and feedback
// category breakdowns. One value rendered by /stats per request.
type StatsSnapshot struct {
Users int
UsersLast7Days int
Posts int
PostsLast7Days int
Collections int
PublicCollections int
Bookmarks int
Reshares int
Feedback int
FeedbackLast7Days int
FeedbackByCategory []CategoryCount
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
Count int
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{}
// Non-nil so an empty table serialises as [] rather than null.
s := &StatsSnapshot{FeedbackByCategory: []CategoryCount{}}
sevenAgo := time.Now().Add(-7 * 24 * time.Hour)
scalars := []struct {
+74
View File
@@ -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 {
+143 -10
View File
@@ -36,7 +36,9 @@ func NewUserRepo(pg *Postgres) *UserRepo {
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`
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.
@@ -50,7 +52,9 @@ func scanUser(row rowScanner, u *model.User) error {
&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.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt,
&u.BookmarkLimit, &u.PublicCollectionLimit, &u.PrivateCollectionLimit,
&u.DiscipleID, &u.Bio,
)
}
@@ -62,6 +66,7 @@ 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
@@ -228,6 +233,7 @@ func (r *UserRepo) UpdateProfile(ctx context.Context, id string, p ProfileUpdate
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)
@@ -321,39 +327,166 @@ func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error
// 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 int) ([]model.User, error) {
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{}, nil
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) AND banned_at IS NULL`,
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
WHERE (name ILIKE $1 OR handle::text ILIKE $1)
-- Suspended accounts are unsearchable. This one query backs the
-- people search page, the mention autocomplete AND both chat member
-- pickers, so a banned account appearing here is offered as someone
-- to @mention or add to a group. ResolveHandles already refuses them
-- on the way in, which without this makes "Add" silently do nothing.
AND banned_at IS NULL
ORDER BY
CASE
WHEN handle::text ILIKE $2 THEN 0
WHEN name ILIKE $2 THEN 1
ELSE 2
END,
name
LIMIT $3`
name,
handle
LIMIT $3 OFFSET $4`
rows, err := r.pg.Pool.Query(ctx, sql, substring, prefix, limit)
rows, err := r.pg.Pool.Query(ctx, sql, substring, prefix, limit, offset)
if err != nil {
return nil, fmt.Errorf("search users: %w", err)
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, fmt.Errorf("scan user: %w", err)
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
-- Suspended accounts are not discoverable. A ban hides someone from
-- the app; surfacing them in the welcome strip would be the most
-- prominent placement on the home page.
AND u.banned_at IS NULL
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)
}
+348
View File
@@ -0,0 +1,348 @@
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).
var (
// ErrVaniRefInvalid is a malformed content id.
ErrVaniRefInvalid = errors.New("not a valid content reference")
// ErrVaniRefNotFound is a well-formed id that matches nothing.
ErrVaniRefNotFound = errors.New("that content no longer exists")
)
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
}
// Upsert writes a day's pick, REPLACING any existing one.
//
// Distinct from Insert, which is ON CONFLICT DO NOTHING because the automatic
// picker must never overwrite a day already decided. An admin choosing the day
// deliberately is the one case that should win.
func (r *VaniRepo) Upsert(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 UPDATE
SET kind = EXCLUDED.kind,
ref_id = EXCLUDED.ref_id,
payload = EXCLUDED.payload`,
pick.Day, pick.Kind, pick.RefID, b)
if err != nil {
return fmt.Errorf("upsert vani pick: %w", err)
}
return nil
}
// Clear removes a day's pick, so the automatic picker chooses again.
func (r *VaniRepo) Clear(ctx context.Context, day time.Time) error {
_, err := r.pg.Pool.Exec(ctx, `DELETE FROM vani_of_the_day WHERE day = $1`, day)
if err != nil {
return fmt.Errorf("clear vani pick: %w", err)
}
return nil
}
// PicksFrom lists decided days from `from` onward, for the admin schedule.
func (r *VaniRepo) PicksFrom(ctx context.Context, from time.Time, limit int) ([]model.VaniPick, error) {
rows, err := r.pg.Pool.Query(ctx,
`SELECT day, kind, ref_id, payload FROM vani_of_the_day
WHERE day >= $1 ORDER BY day LIMIT $2`, from, limit)
if err != nil {
return nil, fmt.Errorf("list vani picks: %w", err)
}
defer rows.Close()
out := []model.VaniPick{}
for rows.Next() {
var p model.VaniPick
var raw []byte
if err := rows.Scan(&p.Day, &p.Kind, &p.RefID, &raw); err != nil {
return nil, fmt.Errorf("scan vani pick: %w", err)
}
if err := json.Unmarshal(raw, &p.Payload); err != nil {
return nil, fmt.Errorf("decode vani payload: %w", err)
}
out = append(out, p)
}
return out, rows.Err()
}
// 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()
}
// oneByHex loads a single document by its _id hex. Shared by the three
// admin-pick lookups, which differ only in collection and target type.
func (r *VaniRepo) oneByHex(ctx context.Context, coll, hex string, out interface{}) error {
oid, err := bson.ObjectIDFromHex(hex)
if err != nil {
return ErrVaniRefInvalid
}
err = r.mongo.Collection(coll).FindOne(ctx, bson.D{{Key: "_id", Value: oid}}).Decode(out)
if errors.Is(err, mongo.ErrNoDocuments) {
return ErrVaniRefNotFound
}
if err != nil {
return fmt.Errorf("load %s %s: %w", coll, hex, err)
}
return nil
}
// TranscriptBySlug loads a lecture by its URL slug.
//
// Search cannot do this: the slug is not an indexed field, so querying
// "720720sb-london" matches on tokens like "london" and returns whatever else
// mentions it. Resolving the slug directly is the only reliable way to pin a
// specific item someone already has the URL for.
func (r *VaniRepo) TranscriptBySlug(ctx context.Context, slug string) (*TranscriptionDoc, error) {
var t TranscriptionDoc
err := r.mongo.Collection("transcriptions").
FindOne(ctx, bson.D{{Key: "slug", Value: slug}}).Decode(&t)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrVaniRefNotFound
}
if err != nil {
return nil, fmt.Errorf("transcript by slug: %w", err)
}
return &t, nil
}
// ProseBySlug loads a prose chapter by book and slug.
func (r *VaniRepo) ProseBySlug(ctx context.Context, book, slug string) (*ProseChapterDoc, error) {
var p ProseChapterDoc
err := r.mongo.Collection("prose_chapters").FindOne(ctx, bson.D{
{Key: "book", Value: book},
{Key: "slug", Value: slug},
}).Decode(&p)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrVaniRefNotFound
}
if err != nil {
return nil, fmt.Errorf("prose by slug: %w", err)
}
return &p, nil
}
// VerseByRef loads a verse by its book/division/chapter/verse coordinates,
// which is what a verse URL carries.
func (r *VaniRepo) VerseByRef(ctx context.Context, book string, division *string, chapter, verse int) (*VerseDoc, error) {
filter := bson.D{
{Key: "book", Value: book},
{Key: "chapter", Value: chapter},
{Key: "verse", Value: verse},
}
if division != nil && *division != "" {
filter = append(filter, bson.E{Key: "division", Value: *division})
}
var v VerseDoc
err := r.mongo.Collection("verses").FindOne(ctx, filter).Decode(&v)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrVaniRefNotFound
}
if err != nil {
return nil, fmt.Errorf("verse by ref: %w", err)
}
return &v, nil
}
// VerseByHex / ProseByHex / TranscriptByHex load a specific item an admin has
// chosen. The payload is then built by the same builders the random picker
// uses, so a pinned pick renders identically to an automatic one.
func (r *VaniRepo) VerseByHex(ctx context.Context, hex string) (*VerseDoc, error) {
var v VerseDoc
if err := r.oneByHex(ctx, "verses", hex, &v); err != nil {
return nil, err
}
return &v, nil
}
func (r *VaniRepo) ProseByHex(ctx context.Context, hex string) (*ProseChapterDoc, error) {
var p ProseChapterDoc
if err := r.oneByHex(ctx, "prose_chapters", hex, &p); err != nil {
return nil, err
}
return &p, nil
}
func (r *VaniRepo) TranscriptByHex(ctx context.Context, hex string) (*TranscriptionDoc, error) {
var t TranscriptionDoc
if err := r.oneByHex(ctx, "transcriptions", hex, &t); err != nil {
return nil, err
}
return &t, nil
}
// 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
}
+573 -27
View File
@@ -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"
@@ -11,12 +12,16 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/config"
"github.com/prabhupadasays/spsays-bff/internal/handler"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/rbac"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"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"
)
@@ -30,8 +35,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
@@ -44,18 +55,34 @@ 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
chatH *handler.ChatHandler
notificationH *handler.NotificationHandler
sadhnaH *handler.SadhnaHandler
pushH *handler.PushHandler
reminderH *handler.ReminderHandler
feedbackH *handler.FeedbackHandler
feedbackCatH *handler.FeedbackCategoryHandler
statsH *handler.StatsHandler
adminH *handler.AdminHandler
broadcastH *handler.BroadcastHandler
staffRepo *repository.StaffRepo
staffH *handler.StaffHandler
quizH *handler.QuizHandler
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
authIssuer *auth.Issuer
@@ -82,23 +109,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),
bookH: handler.NewBookHandler(bookRepo),
letterH: handler.NewLetterHandler(letterRepo),
essayH: handler.NewEssayHandler(essayRepo),
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
// 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.
@@ -133,6 +165,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))
@@ -151,13 +184,22 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
}, logger)
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)
bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo)
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
@@ -168,6 +210,29 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
s.userH = handler.NewUserHandler(userSvc)
// 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))
// Quizzes reuse the vāṇī content lookups to resolve a pasted link into a
// target title and kind.
s.quizH = handler.NewQuizHandler(service.NewQuizService(repository.NewQuizRepo(s.pg), vaniRepo))
// 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.
@@ -197,20 +262,112 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
blockRepo := repository.NewBlockRepo(s.pg)
reportRepo := repository.NewReportRepo(s.pg)
notifRepo := repository.NewNotificationRepo(s.pg)
notifSvc := service.NewNotificationService(notifRepo, userRepo, mail, logger, cfg.FrontendURL)
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
rtClient := realtime.NewSoketiClient(
cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret,
cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger,
)
pushRepo := repository.NewPushRepo(s.pg)
pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger)
s.pushH = handler.NewPushHandler(pushSender, pushRepo)
notifPrefsRepo := repository.NewNotificationPrefsRepo(s.pg)
notifSvc := service.NewNotificationService(notifRepo, notifPrefsRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL)
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
chatRepo := repository.NewChatRepo(s.pg)
chatSvc := service.NewChatService(chatRepo, blockRepo, postRepo,
pushSender, notifPrefsRepo, userRepo, mail, cfg.FrontendURL,
rtClient, r2, s.redis, logger)
s.chatH = handler.NewChatHandler(chatSvc)
// Soketi signs webhooks with the app secret. channel_occupied/vacated on a
// per-user notifications channel tells us whether they are connected: we
// evict the feed seen-set when they leave, and chat uses the same signal
// to skip pushing to someone who is already in the app. Constructed after
// chatSvc because it depends on it.
s.soketiWebhookH = handler.NewSoketiWebhookHandler(
cfg.SoketiAppKey, cfg.SoketiAppSecret, feedRepo, chatSvc, logger)
// Channel authorization for private-* subscriptions. Rules are explicit
// and default-deny — an unlisted channel is refused.
s.broadcastAuthH = handler.NewBroadcastingAuthHandler(rtClient, logger,
handler.NotificationsRule(),
handler.ConversationRule(chatSvc.IsParticipant),
)
// 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, chatRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
s.relationH = handler.NewRelationshipHandler(graphSvc)
s.notificationH = handler.NewNotificationHandler(notifSvc)
s.postH = handler.NewPostHandler(postSvc, graphSvc)
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc)
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc, likeRepo, 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, r2, 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)
// Categories are admin-managed; submissions are checked against them.
feedbackCatRepo := repository.NewFeedbackCategoryRepo(s.pg)
feedbackSvc := service.NewFeedbackService(feedbackRepo, feedbackCatRepo, mail, cfg.FeedbackAdminEmails, logger)
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo, feedbackSvc)
s.feedbackCatH = handler.NewFeedbackCategoryHandler(service.NewFeedbackCategoryService(feedbackCatRepo))
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)
// Admin RBAC: who is staff, with which permissions.
s.staffRepo = repository.NewStaffRepo(s.pg)
s.adminH = handler.NewAdminHandler(adminSvc, s.staffRepo)
s.staffH = handler.NewStaffHandler(service.NewStaffService(s.staffRepo, mail, logger, cfg.AdminURL))
// Broadcasts — admin announcements shown once per user. No notification
// rows: the modal is the surface, and push plus the realtime nudge only
// tell clients to go and check for one.
broadcastSvc := service.NewBroadcastService(
repository.NewBroadcastRepo(s.pg), pushSender, pushRepo,
notifPrefsRepo, rtClient, r2, logger)
s.broadcastH = handler.NewBroadcastHandler(broadcastSvc)
}
// initShortener wires Postgres + Redis + the shorturl handler/workers.
@@ -219,6 +376,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 {
@@ -275,6 +433,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.
@@ -302,14 +483,31 @@ 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.
adminURL := s.cfg.AdminURL
if adminURL == "" && s.cfg.Env == "development" {
// The admin app's dev server (`next dev -p 3001`), so local admin
// work needs no extra config. Production must set ADMIN_URL.
adminURL = "http://localhost:3001"
}
allowedOrigins := []string{}
for _, o := range []string{s.cfg.FrontendURL, 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"},
@@ -328,36 +526,46 @@ func (s *Server) setupRouter() {
// 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)
// One-way per-row feedback actions — POST so a refresh of /stats
// can't replay them. Both live at the same public access level as
// /stats itself (gate them together if that ever changes).
r.Post("/stats/feedback/{id}/acknowledge", s.statsH.AcknowledgeFeedback)
r.Post("/stats/feedback/{id}/resolve", s.statsH.ResolveFeedback)
}
// API v1
r.Route("/api/v1", func(r chi.Router) {
// Sitemap — flat list of public content URLs (consumed by the
// frontend's daily-cached sitemap routes). Public, read-only.
r.Get("/sitemap/content", s.sitemapH.GetContent)
// Public user profiles (Postgres) — separate from Mongo content.
r.Get("/sitemap/users", s.sitemapUsers)
// Books
r.Route("/books", func(r chi.Router) {
r.Get("/", s.bookH.ListBooks)
// --- Verse books with divisions ---
// SB — canto divisions
// SB — canto divisions (plus prose front-matter segments;
// the static /chapters routes take precedence over /{division}).
r.Route("/sb", func(r chi.Router) {
r.Use(injectBookCode("sb"))
r.Get("/", s.bookH.GetBook)
r.Get("/chapters", s.bookH.ListProseChapters)
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
r.Get("/{division}", s.bookH.GetDivisionInfo)
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
r.Get("/{division}/{chapter}/{verse}", s.bookH.GetDivisionVerse)
})
// CC — līlā divisions
// CC — līlā divisions (plus prose front-matter segments;
// the static /chapters routes take precedence over /{division}).
r.Route("/cc", func(r chi.Router) {
r.Use(injectBookCode("cc"))
r.Get("/", s.bookH.GetBook)
r.Get("/chapters", s.bookH.ListProseChapters)
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
r.Get("/{division}", s.bookH.GetDivisionInfo)
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
@@ -365,8 +573,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))
@@ -378,8 +586,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))
@@ -470,6 +679,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
@@ -502,9 +714,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)
})
@@ -512,6 +726,29 @@ 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)
// Quizzes are for signed-in users: listing, finding one on a page,
// taking it and reading results all need an account. The one open
// endpoint is a quiz's own card — title, description, what it's
// attached to, never its questions — so link previews (OpenGraph)
// can name the quiz when it's shared.
r.Route("/quizzes", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/{id}", s.quizH.Get)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/", s.quizH.List)
r.Get("/for", s.quizH.ForPath)
r.Post("/{id}/start", s.quizH.Start)
r.Post("/{id}/answer", s.quizH.Answer)
r.Get("/{id}/result", s.quizH.Result)
})
})
// --- Auth ---
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
// and email verification. The /auth/me + /auth/resend-verification routes
@@ -577,6 +814,13 @@ func (s *Server) setupRouter() {
// 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)
})
})
@@ -592,6 +836,14 @@ func (s *Server) setupRouter() {
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/{id}", s.postH.Get)
// Paginated "liked by" list — public, like the count itself.
r.Get("/{id}/likes", s.postH.ListLikes)
// 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.
@@ -599,16 +851,59 @@ func (s *Server) setupRouter() {
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)
})
})
@@ -658,6 +953,105 @@ func (s *Server) setupRouter() {
// 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)
}
// Chat — inbox, threads, messages. Every route requires a session;
// membership is re-checked per request in the service, so a
// conversation id on its own grants nothing.
if s.broadcastH != nil {
// Announcements: fetch the one waiting, and dismiss it.
r.Route("/broadcasts", func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/pending", s.broadcastH.Pending)
r.Post("/{id}/seen", s.broadcastH.Seen)
})
}
if s.chatH != nil {
r.Route("/chat", func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/conversations", s.chatH.Inbox)
r.Get("/unread", s.chatH.Unread)
r.Get("/stickers", s.chatH.Stickers)
r.Post("/conversations/dm", s.chatH.OpenDM)
r.Post("/conversations/group", s.chatH.CreateGroup)
r.Post("/conversations/kautilya", s.chatH.OpenAIThread)
r.Post("/link-preview", s.chatH.LinkPreview)
r.Get("/settings", s.chatH.Settings)
r.Patch("/settings", s.chatH.UpdateSettings)
r.Get("/attachments/{attachmentID}", s.chatH.Attachment)
r.Route("/conversations/{id}", func(r chi.Router) {
r.Get("/", s.chatH.Detail)
r.Patch("/", s.chatH.UpdateGroup)
r.Post("/members", s.chatH.AddMembers)
r.Delete("/members/{handle}", s.chatH.RemoveMember)
r.Patch("/members/{handle}", s.chatH.SetMemberRole)
r.Get("/messages", s.chatH.Messages)
r.Post("/messages", s.chatH.Send)
r.Post("/read", s.chatH.MarkRead)
r.Post("/typing", s.chatH.Typing)
r.Post("/delivered", s.chatH.MarkDelivered)
r.Get("/receipts", s.chatH.Receipts)
r.Post("/mute", s.chatH.SetMute)
r.Post("/attachments/presign", s.chatH.PresignAttachment)
r.Post("/avatar/presign", s.chatH.PresignGroupAvatar)
r.Get("/avatar", s.chatH.GroupAvatar)
r.Get("/gallery", s.chatH.Gallery)
r.Get("/starred", s.chatH.Starred)
r.Post("/messages/{messageID}/star", s.chatH.Star)
r.Delete("/messages/{messageID}/star", s.chatH.Unstar)
r.Get("/messages/{messageID}", s.chatH.Message)
r.Get("/messages/{messageID}/reactions", s.chatH.MessageReactions)
r.Post("/messages/{messageID}/sticker", s.chatH.SaveSticker)
r.Put("/messages/{messageID}/reaction", s.chatH.React)
r.Delete("/messages/{messageID}/reaction", s.chatH.Unreact)
r.Delete("/messages/{messageID}", s.chatH.DeleteMessage)
r.Post("/messages/{messageID}/report", s.chatH.ReportMessage)
r.Post("/forward", s.chatH.Forward)
})
})
}
// 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)
@@ -670,6 +1064,9 @@ func (s *Server) setupRouter() {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/{handle}", s.profileH.Get)
r.Get("/{handle}/feed", s.profileH.Feed)
r.Get("/{handle}/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
@@ -716,10 +1113,22 @@ func (s *Server) setupRouter() {
r.Group(func(r chi.Router) {
if s.feedbackH == nil {
r.HandleFunc("/feedback", s.authUnavailable)
r.HandleFunc("/feedback/mine", s.authUnavailable)
return
}
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Post("/feedback", s.feedbackH.Submit)
// The form's categories — public, like the form itself.
r.Get("/feedback/categories", s.feedbackCatH.Public)
// 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.
@@ -734,6 +1143,143 @@ func (s *Server) setupRouter() {
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)
r.Post("/rewards/gifts/{key}/open", s.sadhnaH.OpenGift)
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))
// Staff only past this point; each route then names the one
// permission it needs. A route without a permission is reachable
// by every staff member, so only /me is left bare.
r.Use(middleware.RequireStaff(s.staffRepo))
can := func(perm string) chi.Router {
return r.With(middleware.RequirePermission(perm))
}
r.Get("/me", s.staffH.Me)
// Staff and roles — superadmins only.
can(rbac.ManageStaff).Get("/permissions", s.staffH.Catalog)
can(rbac.ManageStaff).Get("/staff", s.staffH.List)
can(rbac.ManageStaff).Post("/staff", s.staffH.Grant)
can(rbac.ManageStaff).Patch("/staff/{id}", s.staffH.ChangeRole)
can(rbac.ManageStaff).Delete("/staff/{id}", s.staffH.Revoke)
can(rbac.ManageStaff).Post("/staff/{id}/block", s.staffH.Block)
can(rbac.ManageStaff).Delete("/staff/{id}/block", s.staffH.Unblock)
can(rbac.ManageStaff).Get("/roles", s.staffH.Roles)
can(rbac.ManageStaff).Post("/roles", s.staffH.CreateRole)
can(rbac.ManageStaff).Patch("/roles/{id}", s.staffH.UpdateRole)
can(rbac.ManageStaff).Delete("/roles/{id}", s.staffH.DeleteRole)
can("stats:read").Get("/stats", s.adminH.Stats)
can("users:read").Get("/users", s.adminH.ListUsers)
can("users:notify").Post("/users/{id}/notify", s.adminH.NotifyUser)
can("users:ban").Post("/users/{id}/ban", s.adminH.BanUser)
can("users:ban").Delete("/users/{id}/ban", s.adminH.UnbanUser)
can("users:limits").Post("/users/{id}/limits", s.adminH.SetUserLimits)
can("users:read").Get("/disciples", s.adminH.ListDisciples)
can("disciples:manage").Post("/users/{id}/disciple", s.adminH.LinkDisciple)
can("disciples:manage").Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
can("vani:read").Get("/vani", s.vaniH.Schedule)
can("vani:write").Post("/vani", s.vaniH.Set)
can("vani:write").Post("/vani/by-url", s.vaniH.SetByURL)
can("vani:write").Delete("/vani/{day}", s.vaniH.Clear)
can("rewards:read").Get("/reward-gifts/catalog", s.sadhnaH.GiftableRewards)
can("rewards:send").Post("/reward-gifts", s.sadhnaH.GiftToEveryone)
can("rewards:write").Post("/reward-gifts/catalog", s.sadhnaH.CreateCustomGift)
can("rewards:write").Post("/reward-gifts/image-presign", s.sadhnaH.PresignGiftImage)
can("rewards:delete").Delete("/reward-gifts/{key}", s.sadhnaH.DeleteCustomGift)
can("broadcasts:read").Get("/broadcasts", s.broadcastH.List)
can("broadcasts:write").Post("/broadcasts", s.broadcastH.Create)
can("broadcasts:write").Post("/broadcasts/banner-presign", s.broadcastH.PresignBanner)
can("broadcasts:publish").Post("/broadcasts/{id}/publish", s.broadcastH.Publish)
can("broadcasts:write").Delete("/broadcasts/{id}", s.broadcastH.Delete)
can("posts:moderate").Get("/posts", s.adminH.ListPosts)
can("posts:moderate").Post("/posts/{id}/hide", s.adminH.HidePost)
can("posts:moderate").Delete("/posts/{id}/hide", s.adminH.UnhidePost)
can("posts:moderate").Delete("/posts/{id}", s.adminH.DeletePost)
can("posts:moderate").Post("/posts/{id}/restore", s.adminH.RestorePost)
can("comments:moderate").Delete("/comments/{id}", s.adminH.DeleteComment)
can("comments:moderate").Post("/comments/{id}/restore", s.adminH.RestoreComment)
can("collections:read").Get("/collections", s.adminH.ListCollections)
can("feedback:read").Get("/feedback", s.adminH.ListFeedback)
can("feedback:read").Get("/feedback/categories", s.feedbackCatH.List)
can("feedback:categories").Post("/feedback/categories", s.feedbackCatH.Create)
can("feedback:categories").Put("/feedback/categories/order", s.feedbackCatH.Reorder)
can("feedback:categories").Put("/feedback/categories/{key}", s.feedbackCatH.Update)
can("feedback:categories").Delete("/feedback/categories/{key}", s.feedbackCatH.Delete)
can("feedback:reply").Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
can("feedback:resolve").Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
can("feedback:resolve").Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
can("reports:read").Get("/reports", s.adminH.ListReports)
can("reports:resolve").Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
can("quizzes:read").Get("/quizzes", s.quizH.AdminList)
can("quizzes:read").Get("/quizzes/{id}", s.quizH.AdminGet)
can("quizzes:write").Post("/quizzes", s.quizH.Create)
can("quizzes:write").Put("/quizzes/{id}", s.quizH.Update)
can("quizzes:write").Post("/quizzes/{id}/publish", s.quizH.Publish)
can("quizzes:write").Delete("/quizzes/{id}/publish", s.quizH.Unpublish)
can("quizzes:delete").Delete("/quizzes/{id}", s.quizH.Delete)
can("quizzes:results").Get("/quizzes/{id}/participants", s.quizH.Participants)
can("quizzes:results").Get("/quizzes/{id}/participants/{userID}", s.quizH.ParticipantResult)
can("quizzes:results").Get("/quizzes/{id}/responses", s.quizH.Responses)
can("quizzes:reset").Delete("/quizzes/{id}/participants/{userID}", s.quizH.ResetParticipant)
can("short_urls:read").Get("/short-urls", s.adminH.ListShortURLs)
can("short_urls:read").Get("/short-urls/stats", s.adminH.ShortURLStats)
})
})
+448
View File
@@ -0,0 +1,448 @@
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"
"github.com/prabhupadasays/spsays-bff/pkg/richtext"
"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,
// Rendered markers; escaped by richtext, newlines become <br>.
"Reply": richtext.ToHTML(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="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>`))
+25
View File
@@ -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)
}
+20
View File
@@ -21,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"
)
@@ -34,6 +35,7 @@ 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.
@@ -45,12 +47,17 @@ type AuthService struct {
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
}
// 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(
@@ -140,6 +147,9 @@ 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)
}
@@ -184,6 +194,11 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*Login
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
@@ -224,6 +239,9 @@ func (s *AuthService) VerifyLoginOTP(ctx context.Context, email, password, otp s
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)
@@ -445,6 +463,8 @@ func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*
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)
}
+268
View File
@@ -0,0 +1,268 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
"go.uber.org/zap"
)
var (
ErrBroadcastTitle = errors.New("a title is required")
ErrBroadcastBody = errors.New("a description is required")
ErrBroadcastCTA = errors.New("that CTA link is not a valid URL")
ErrBroadcastBanner = errors.New("that image was not uploaded for a broadcast")
)
const (
maxBroadcastTitleRunes = 200
maxBroadcastBodyRunes = 4000
// bannerPrefix namespaces broadcast banners in the PUBLIC bucket. An
// announcement is not private, and a signed URL would expire while the
// modal sat open on someone's screen.
bannerPrefix = "broadcasts/"
)
// BroadcastChannel is the PUBLIC realtime channel every client watches for
// announcements.
//
// Public is deliberate and safe because the event carries no content — just
// "something was published". Each client then fetches /broadcasts/pending,
// which is authenticated and per-user, so someone who has already dismissed it
// gets nothing. That also keeps publishing to one Soketi call instead of one
// per user.
const BroadcastChannel = "broadcasts"
type BroadcastService struct {
repo *repository.BroadcastRepo
push *PushSender
pushes *repository.PushRepo
prefs *repository.NotificationPrefsRepo
rt *realtime.SoketiClient
r2 *repository.R2
logger *zap.Logger
}
func NewBroadcastService(
repo *repository.BroadcastRepo,
push *PushSender,
pushes *repository.PushRepo,
prefs *repository.NotificationPrefsRepo,
rt *realtime.SoketiClient,
r2 *repository.R2,
logger *zap.Logger,
) *BroadcastService {
return &BroadcastService{
repo: repo, push: push, pushes: pushes, prefs: prefs,
rt: rt, r2: r2, logger: logger,
}
}
// CreateParams is an admin-authored announcement.
type CreateParams struct {
Title string
Body string
BannerKey *string
CTALabel *string
CTAURL *string
ExpiresAt *time.Time
}
// Create stores a draft. Nothing is delivered until Publish.
func (s *BroadcastService) Create(ctx context.Context, adminID string, p CreateParams) (*model.Broadcast, error) {
title := strings.TrimSpace(p.Title)
body := strings.TrimSpace(p.Body)
if title == "" || len([]rune(title)) > maxBroadcastTitleRunes {
return nil, ErrBroadcastTitle
}
if body == "" || len([]rune(body)) > maxBroadcastBodyRunes {
return nil, ErrBroadcastBody
}
if p.CTAURL != nil && strings.TrimSpace(*p.CTAURL) != "" {
clean, ok := safeCTAURL(*p.CTAURL)
if !ok {
return nil, ErrBroadcastCTA
}
p.CTAURL = &clean
} else {
p.CTAURL = nil
}
if p.BannerKey != nil && !strings.HasPrefix(*p.BannerKey, bannerPrefix) {
return nil, ErrBroadcastBanner
}
// Same parser as the chat composer, so the admin writes *bold* / ~strike~ /
// `code` and links are detected — and no HTML is ever stored.
clean, entities := ParseEntities(body)
encoded, err := json.Marshal(entities)
if err != nil {
return nil, fmt.Errorf("encode entities: %w", err)
}
if entities == nil {
encoded = []byte(`[]`)
}
b, key, err := s.repo.Create(ctx, repository.CreateBroadcastParams{
Title: title, Body: clean, Entities: encoded, BannerKey: p.BannerKey,
CTALabel: p.CTALabel, CTAURL: p.CTAURL, ExpiresAt: p.ExpiresAt,
CreatedBy: adminID,
})
if err != nil {
return nil, err
}
s.withBanner(b, key)
return b, nil
}
// Publish makes a broadcast live, then nudges everyone: connected clients over
// realtime, absent ones by push.
func (s *BroadcastService) Publish(ctx context.Context, id string) (*model.Broadcast, error) {
b, key, err := s.repo.Publish(ctx, id)
if err != nil {
return nil, err
}
s.withBanner(b, key)
// One trigger for the whole user base. Content-free on purpose — clients
// re-fetch their own pending state.
if s.rt != nil {
if err := s.rt.Trigger(ctx, BroadcastChannel, "broadcast_published", map[string]any{}); err != nil {
s.logger.Warn("broadcast: realtime nudge failed", zap.Error(err))
}
}
go s.fanOutPush(context.WithoutCancel(ctx), b)
return b, nil
}
// fanOutPush notifies everyone with a push subscription who hasn't opted out
// of announcements.
//
// Runs detached from the request: a large user base takes far longer than an
// admin should wait on a button.
func (s *BroadcastService) fanOutPush(ctx context.Context, b *model.Broadcast) {
if s.push == nil || !s.push.Enabled() || s.pushes == nil {
return
}
users, err := s.pushes.SubscribedUserIDs(ctx)
if err != nil {
s.logger.Warn("broadcast: listing subscribers failed", zap.Error(err))
return
}
sent := 0
for _, uid := range users {
if s.prefs != nil {
// Reuses the existing "Community" category, described in settings
// as rare announcements — which is exactly this. Someone who
// opted out of those stays opted out.
if off, err := s.prefs.DisabledSet(ctx, uid); err == nil &&
prefOff(off, NotifDiscipleJoined, repository.ChannelPush) {
continue
}
}
s.push.SendToUser(ctx, uid, PushPayload{
Title: b.Title,
Body: truncateRunes(b.Body, 160),
URL: "/",
// One tag per broadcast: a re-publish replaces rather than stacks.
Tag: "broadcast:" + b.ID,
})
sent++
}
s.logger.Info("broadcast: push fan-out complete",
zap.String("broadcast_id", b.ID), zap.Int("recipients", sent))
}
// Pending returns the announcement waiting for this user, or nil.
func (s *BroadcastService) Pending(ctx context.Context, userID string) (*model.Broadcast, error) {
b, key, err := s.repo.Pending(ctx, userID)
if err != nil || b == nil {
return nil, err
}
s.withBanner(b, key)
return b, nil
}
// MarkSeen records that a user dismissed it — the "at most once" half.
func (s *BroadcastService) MarkSeen(ctx context.Context, id, userID string) error {
return s.repo.MarkSeen(ctx, id, userID)
}
func (s *BroadcastService) List(ctx context.Context, limit int) ([]model.Broadcast, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
items, keys, err := s.repo.List(ctx, limit)
if err != nil {
return nil, err
}
for i := range items {
s.withBanner(&items[i], keys[i])
}
return items, nil
}
func (s *BroadcastService) Delete(ctx context.Context, id string) error {
return s.repo.Delete(ctx, id)
}
// PresignBanner issues an upload URL for a banner image.
func (s *BroadcastService) PresignBanner(ctx context.Context, contentType string) (uploadURL, key string, err error) {
if s.r2 == nil {
return "", "", errors.New("media uploads are not configured")
}
kind, ok := attachmentKindFor(contentType)
if !ok || kind != model.AttachmentImage {
return "", "", ErrChatBadMedia
}
// Returns the KEY: broadcasts.banner_key stores a key, and withBanner
// builds the URL from it at read time.
return presignImageKey(ctx, s.r2, strings.TrimSuffix(bannerPrefix, "/"), "banner", contentType)
}
func (s *BroadcastService) withBanner(b *model.Broadcast, key *string) {
if key == nil || *key == "" || s.r2 == nil {
return
}
url := s.r2.PublicURL(*key)
b.BannerURL = &url
}
// safeCTAURL accepts an in-app path or an absolute http(s) URL.
//
// Admin-authored, so this is a typo guard more than a threat model — but
// `javascript:` and `data:` still have no business in a link the whole user
// base is invited to tap.
func safeCTAURL(raw string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > 2048 {
return "", false
}
if strings.ContainsAny(raw, "\\") || strings.ContainsAny(raw, "\n\r\t") {
return "", false
}
if strings.HasPrefix(raw, "/") {
// Protocol-relative "//evil.example" is not an in-app path.
if strings.HasPrefix(raw, "//") {
return "", false
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "" || u.Host != "" {
return "", false
}
return raw, true
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", false
}
return u.String(), true
}
+166
View File
@@ -0,0 +1,166 @@
package service
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
func testBroadcastService(t *testing.T) (*BroadcastService, *repository.Postgres) {
t.Helper()
uri := os.Getenv("POSTGRES_URI")
if uri == "" {
_ = godotenv.Load(filepath.Join("..", "..", ".env"))
uri = os.Getenv("POSTGRES_URI")
}
if uri == "" {
t.Skip("POSTGRES_URI not set")
}
pg, err := repository.NewPostgres(context.Background(), uri, zap.NewNop())
if err != nil {
t.Skipf("postgres unreachable: %v", err)
}
t.Cleanup(pg.Close)
// Nil push/realtime/R2: these tests are about who sees what, and none of
// those services are reachable from a test run.
return NewBroadcastService(repository.NewBroadcastRepo(pg), nil, nil, nil, nil, nil, zap.NewNop()), pg
}
func bcUser(t *testing.T, pg *repository.Postgres, label string) string {
t.Helper()
id := uuid.NewString()
suffix := id[:8]
if _, err := pg.Pool.Exec(context.Background(), `
INSERT INTO users (id, email, password_hash, name, handle)
VALUES ($1, $2, 'x', $3, $4)`,
id, "bc-"+suffix+"@example.invalid", "BC "+label, "bc_"+suffix); err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
func cleanupBroadcast(t *testing.T, pg *repository.Postgres, id string) {
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM broadcasts WHERE id = $1`, id)
})
}
// "At least and at most once" is the whole contract. A user must be offered a
// published announcement until they dismiss it, and never again after.
func TestBroadcast_ShownExactlyOncePerUser(t *testing.T) {
svc, pg := testBroadcastService(t)
ctx := context.Background()
admin, alice, bob := bcUser(t, pg, "Admin"), bcUser(t, pg, "A"), bcUser(t, pg, "B")
b, err := svc.Create(ctx, admin, CreateParams{
Title: "New: chat", Body: "Direct messages are here.",
})
if err != nil {
t.Fatal(err)
}
cleanupBroadcast(t, pg, b.ID)
// A draft is nobody's business yet.
if got, _ := svc.Pending(ctx, alice); got != nil {
t.Fatalf("an unpublished draft was offered: %+v", got)
}
if _, err := svc.Publish(ctx, b.ID); err != nil {
t.Fatal(err)
}
// At least once: offered, and still offered until dismissed.
for i := 0; i < 2; i++ {
got, err := svc.Pending(ctx, alice)
if err != nil {
t.Fatal(err)
}
if got == nil || got.ID != b.ID {
t.Fatalf("call %d: expected the published announcement, got %+v", i+1, got)
}
}
// At most once: dismissed means gone, and dismissing twice is harmless.
if err := svc.MarkSeen(ctx, b.ID, alice); err != nil {
t.Fatal(err)
}
if err := svc.MarkSeen(ctx, b.ID, alice); err != nil {
t.Errorf("second dismissal should be idempotent, got %v", err)
}
if got, _ := svc.Pending(ctx, alice); got != nil {
t.Errorf("shown again after dismissal: %+v", got)
}
// One person dismissing must not dismiss it for everyone.
if got, _ := svc.Pending(ctx, bob); got == nil || got.ID != b.ID {
t.Errorf("another user lost the announcement: %+v", got)
}
}
// An announcement past its expiry stops being offered, so someone signing up
// months later isn't shown last quarter's news.
func TestBroadcast_ExpiryStopsDelivery(t *testing.T) {
svc, pg := testBroadcastService(t)
ctx := context.Background()
admin, reader := bcUser(t, pg, "Admin"), bcUser(t, pg, "R")
past := time.Now().Add(-time.Hour)
b, err := svc.Create(ctx, admin, CreateParams{
Title: "Old news", Body: "Yesterday's announcement.", ExpiresAt: &past,
})
if err != nil {
t.Fatal(err)
}
cleanupBroadcast(t, pg, b.ID)
if _, err := svc.Publish(ctx, b.ID); err != nil {
t.Fatal(err)
}
if got, _ := svc.Pending(ctx, reader); got != nil {
t.Errorf("an expired announcement was offered: %+v", got)
}
}
// Only the newest announcement is offered: two modals stacked on launch is a
// worse experience than missing the older one.
func TestBroadcast_OffersNewestFirst(t *testing.T) {
svc, pg := testBroadcastService(t)
ctx := context.Background()
admin, reader := bcUser(t, pg, "Admin"), bcUser(t, pg, "R")
older, err := svc.Create(ctx, admin, CreateParams{Title: "First", Body: "one"})
if err != nil {
t.Fatal(err)
}
cleanupBroadcast(t, pg, older.ID)
if _, err := svc.Publish(ctx, older.ID); err != nil {
t.Fatal(err)
}
newer, err := svc.Create(ctx, admin, CreateParams{Title: "Second", Body: "two"})
if err != nil {
t.Fatal(err)
}
cleanupBroadcast(t, pg, newer.ID)
if _, err := svc.Publish(ctx, newer.ID); err != nil {
t.Fatal(err)
}
got, err := svc.Pending(ctx, reader)
if err != nil {
t.Fatal(err)
}
if got == nil || got.ID != newer.ID {
t.Errorf("expected the newest announcement, got %+v", got)
}
}
+208
View File
@@ -0,0 +1,208 @@
package service
import (
"regexp"
"strings"
"unicode"
)
// Message formatting.
//
// The composer accepts three inline markers — *bold*, ~strike~, `code` — and
// auto-detects links. Rather than store markup, we store CLEAN text plus a list
// of spans describing what to decorate. Two reasons: rendering spans can't
// inject HTML no matter what a user types, and every client (web today, native
// later) applies them natively instead of parsing a markup dialect.
//
// OFFSETS ARE UTF-16 CODE UNITS, not bytes and not runes. JavaScript strings
// are UTF-16, so `body.slice(offset, offset+length)` in the client only lines
// up if we count the same way it does. This matters constantly here: Devanāgarī
// and IAST are multi-byte, and emoji are surrogate pairs that count as 2.
// EntityType values understood by the renderer.
const (
EntityBold = "bold"
EntityStrike = "strike"
EntityCode = "code"
EntityURL = "url"
)
// Entity marks a formatted span of a message body.
type Entity struct {
Type string `json:"type"`
Offset int `json:"offset"` // in UTF-16 code units
Length int `json:"length"` // in UTF-16 code units
// URL carries the resolved href for EntityURL spans, so "www.x.org" in the
// text becomes a usable "https://www.x.org" without the client guessing.
URL string `json:"url,omitempty"`
}
// markers maps the opening/closing rune to the entity it produces. Code is
// handled with the same table but its contents are treated as literal.
var markers = map[rune]string{
'*': EntityBold,
'~': EntityStrike,
'`': EntityCode,
}
// urlRe matches http(s) URLs and bare www. hosts. Deliberately conservative:
// matching every bare domain turns "e.g" and "Bg 2.13" into links.
var urlRe = regexp.MustCompile(`(?i)(?:https?://|www\.)[^\s<>()\[\]{}"']+`)
// trailing punctuation that is far more likely to be sentence punctuation than
// part of the URL.
const urlTrailing = `.,;:!?'"`
// ParseEntities converts raw composer text into clean body text plus the spans
// describing its formatting. Markers are removed from the body — what the
// reader copies is the text, not the syntax.
func ParseEntities(raw string) (string, []Entity) {
var b strings.Builder
entities, _ := parseInto(&b, raw, 0)
body := b.String()
// Links are detected last, over the finished body, so offsets need no
// adjustment. Code spans are skipped: a URL inside `backticks` is being
// shown, not linked.
entities = append(entities, detectURLs(body, entities)...)
sortEntities(entities)
return body, entities
}
// parseInto appends the cleaned text of raw to b and returns its entities plus
// how many UTF-16 units were written. Returning the written count (rather than
// deriving it from the input) keeps offsets correct however many markers a
// nested parse strips.
func parseInto(b *strings.Builder, raw string, base int) (entities []Entity, written int) {
runes := []rune(raw)
for i := 0; i < len(runes); i++ {
r := runes[i]
kind, isMarker := markers[r]
if !isMarker {
b.WriteRune(r)
written += utf16Len(r)
continue
}
close := findClose(runes, i)
if close < 0 {
// An unmatched marker is just a character the user typed.
b.WriteRune(r)
written += utf16Len(r)
continue
}
inner := string(runes[i+1 : close])
start := base + written
if kind == EntityCode {
// Code is literal: markers inside it are content, not syntax.
b.WriteString(inner)
written += utf16LenString(inner)
} else {
// Recurse so *bold with `code` inside* works.
nested, n := parseInto(b, inner, start)
entities = append(entities, nested...)
written += n
}
entities = append(entities, Entity{Type: kind, Offset: start, Length: base + written - start})
i = close
}
return entities, written
}
// findClose returns the index of the marker closing the one at open, or -1.
//
// The rules mirror what chat users expect: an opener must start a word and be
// followed by content, a closer must end one. Without them "2*3*4" and
// "a_b_c"-style text would silently turn into formatting.
func findClose(runes []rune, open int) int {
marker := runes[open]
if open > 0 && isWordRune(runes[open-1]) {
return -1 // mid-word: not an opener
}
if open+1 >= len(runes) || unicode.IsSpace(runes[open+1]) || runes[open+1] == marker {
return -1 // empty or space-led content
}
for i := open + 1; i < len(runes); i++ {
if runes[i] != marker {
continue
}
if unicode.IsSpace(runes[i-1]) {
continue // closer must follow content
}
if i+1 < len(runes) && isWordRune(runes[i+1]) {
continue // mid-word: not a closer
}
return i
}
return -1
}
// detectURLs finds links in body, skipping anything already inside a code span.
func detectURLs(body string, existing []Entity) []Entity {
var out []Entity
for _, loc := range urlRe.FindAllStringIndex(body, -1) {
text := strings.TrimRight(body[loc[0]:loc[1]], urlTrailing)
if text == "" {
continue
}
offset := utf16LenString(body[:loc[0]])
length := utf16LenString(text)
if insideCode(existing, offset) {
continue
}
href := text
if !strings.Contains(href, "://") {
href = "https://" + href
}
out = append(out, Entity{Type: EntityURL, Offset: offset, Length: length, URL: href})
}
return out
}
func insideCode(entities []Entity, offset int) bool {
for _, e := range entities {
if e.Type == EntityCode && offset >= e.Offset && offset < e.Offset+e.Length {
return true
}
}
return false
}
// sortEntities orders by offset, then by longer span first, so a renderer can
// open outer decorations before the ones nested inside them.
func sortEntities(entities []Entity) {
for i := 1; i < len(entities); i++ {
for j := i; j > 0; j-- {
a, b := entities[j-1], entities[j]
if a.Offset < b.Offset || (a.Offset == b.Offset && a.Length >= b.Length) {
break
}
entities[j-1], entities[j] = b, a
}
}
}
func isWordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
// utf16Len returns how many UTF-16 code units a rune occupies: 2 for anything
// outside the Basic Multilingual Plane (emoji), 1 otherwise.
func utf16Len(r rune) int {
if r > 0xFFFF {
return 2
}
return 1
}
func utf16LenString(s string) int {
n := 0
for _, r := range s {
n += utf16Len(r)
}
return n
}
+205
View File
@@ -0,0 +1,205 @@
package service
import (
"encoding/json"
"testing"
"unicode/utf16"
)
// utf16Slice extracts a span the way a JavaScript client would, so the tests
// assert the offsets are usable by the actual renderer rather than merely
// self-consistent.
func utf16Slice(s string, offset, length int) string {
u := utf16.Encode([]rune(s))
if offset < 0 || offset+length > len(u) {
return "<out of range>"
}
return string(utf16.Decode(u[offset : offset+length]))
}
func TestParseEntities_Markers(t *testing.T) {
tests := []struct {
name string
raw string
wantBody string
wantSpan map[string]string // entity type -> the text it should cover
}{
{"bold", "hello *world*", "hello world", map[string]string{"bold": "world"}},
{"strike", "~gone~ now", "gone now", map[string]string{"strike": "gone"}},
{"code", "run `go test` please", "run go test please", map[string]string{"code": "go test"}},
{"markers stripped from body", "*a* ~b~ `c`", "a b c", nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body, entities := ParseEntities(tc.raw)
if body != tc.wantBody {
t.Fatalf("body = %q, want %q", body, tc.wantBody)
}
for kind, want := range tc.wantSpan {
found := false
for _, e := range entities {
if e.Type == kind {
if got := utf16Slice(body, e.Offset, e.Length); got != want {
t.Errorf("%s covers %q, want %q", kind, got, want)
}
found = true
}
}
if !found {
t.Errorf("no %s entity in %+v", kind, entities)
}
}
})
}
}
// Offsets are UTF-16 code units because the client slices JS strings with them.
// Devanāgarī is multi-byte and emoji are surrogate pairs, so a byte- or
// rune-based offset would silently mis-highlight in exactly this app's content.
func TestParseEntities_UTF16Offsets(t *testing.T) {
tests := []struct{ name, raw, want string }{
{"devanagari prefix", "श्री कृष्ण *bold*", "bold"},
{"emoji prefix", "🙏🙏 *namaste*", "namaste"},
{"emoji inside span", "*🙏 namaste*", "🙏 namaste"},
{"iast prefix", "Bhagavad-gītā ~old~", "old"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body, entities := ParseEntities(tc.raw)
if len(entities) == 0 {
t.Fatalf("no entities for %q (body %q)", tc.raw, body)
}
e := entities[len(entities)-1]
if got := utf16Slice(body, e.Offset, e.Length); got != tc.want {
t.Errorf("span covers %q, want %q (body %q, entity %+v)", got, tc.want, body, e)
}
})
}
}
// Text that merely contains marker characters must survive untouched.
func TestParseEntities_NotFormatting(t *testing.T) {
for _, raw := range []string{
"2*3*4", // mid-word asterisks
"a~b~c", // mid-word tildes
"* not bold", // space after opener
"unmatched *marker", // no closer
"**", // empty
"5 * 3 = 15", // spaced operators
} {
body, entities := ParseEntities(raw)
if body != raw {
t.Errorf("ParseEntities(%q) rewrote body to %q", raw, body)
}
for _, e := range entities {
if e.Type != EntityURL {
t.Errorf("ParseEntities(%q) produced %s entity %+v", raw, e.Type, e)
}
}
}
}
func TestParseEntities_Nesting(t *testing.T) {
body, entities := ParseEntities("*bold with `code` inside*")
const wantBody = "bold with code inside"
if body != wantBody {
t.Fatalf("body = %q, want %q", body, wantBody)
}
var bold, code *Entity
for i := range entities {
switch entities[i].Type {
case EntityBold:
bold = &entities[i]
case EntityCode:
code = &entities[i]
}
}
if bold == nil || code == nil {
t.Fatalf("want both bold and code, got %+v", entities)
}
if got := utf16Slice(body, bold.Offset, bold.Length); got != wantBody {
t.Errorf("bold covers %q, want the whole body", got)
}
if got := utf16Slice(body, code.Offset, code.Length); got != "code" {
t.Errorf("code covers %q, want %q", got, "code")
}
// Outer span must sort first so a renderer opens it before the nested one.
if entities[0].Type != EntityBold {
t.Errorf("entities[0] = %s, want the outer bold span first", entities[0].Type)
}
}
func TestParseEntities_URLs(t *testing.T) {
tests := []struct {
name string
raw string
wantText string
wantHref string
}{
{"https", "see https://prabhupadasays.org/bg/2/13 here",
"https://prabhupadasays.org/bg/2/13", "https://prabhupadasays.org/bg/2/13"},
{"bare www gets a scheme", "visit www.prabhupadasays.org now",
"www.prabhupadasays.org", "https://www.prabhupadasays.org"},
{"trailing sentence punctuation excluded", "read https://example.org/a.",
"https://example.org/a", "https://example.org/a"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body, entities := ParseEntities(tc.raw)
var url *Entity
for i := range entities {
if entities[i].Type == EntityURL {
url = &entities[i]
}
}
if url == nil {
t.Fatalf("no url entity in %+v", entities)
}
if got := utf16Slice(body, url.Offset, url.Length); got != tc.wantText {
t.Errorf("covers %q, want %q", got, tc.wantText)
}
if url.URL != tc.wantHref {
t.Errorf("href = %q, want %q", url.URL, tc.wantHref)
}
})
}
}
// A URL inside backticks is being shown, not linked.
func TestParseEntities_URLInsideCodeIsNotLinked(t *testing.T) {
_, entities := ParseEntities("run `curl https://example.org` first")
for _, e := range entities {
if e.Type == EntityURL {
t.Errorf("linked a URL inside a code span: %+v", e)
}
}
}
// "Bg 2.13" and "e.g" must not become links — the conservative pattern is what
// keeps scripture references from turning blue.
func TestParseEntities_NoFalsePositiveLinks(t *testing.T) {
for _, raw := range []string{"Bg 2.13 is the verse", "e.g. this one", "version 1.2.3"} {
_, entities := ParseEntities(raw)
for _, e := range entities {
if e.Type == EntityURL {
t.Errorf("ParseEntities(%q) linked %+v", raw, e)
}
}
}
}
// entities is stored as jsonb; empty must round-trip as [] rather than null so
// the column default and the parser agree.
func TestParseEntities_JSONShape(t *testing.T) {
_, entities := ParseEntities("plain text")
if entities == nil {
entities = []Entity{}
}
b, err := json.Marshal(entities)
if err != nil {
t.Fatal(err)
}
if string(b) != "[]" {
t.Errorf("marshalled %s, want []", b)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+220
View File
@@ -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,124 @@
package service
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
const (
maxCategoryLabel = 60
maxCategoryDescription = 200
)
var (
ErrCategoryLabel = fmt.Errorf("a category needs a name (up to %d characters)", maxCategoryLabel)
ErrCategoryDescription = fmt.Errorf("the description can be up to %d characters", maxCategoryDescription)
ErrCategoryLastActive = errors.New("the feedback form needs at least one category")
)
var categoryNonSlug = regexp.MustCompile(`[^a-z0-9]+`)
// FeedbackCategoryService manages the /feedback form's categories.
type FeedbackCategoryService struct {
repo *repository.FeedbackCategoryRepo
}
func NewFeedbackCategoryService(repo *repository.FeedbackCategoryRepo) *FeedbackCategoryService {
return &FeedbackCategoryService{repo: repo}
}
// Active is what the /feedback form offers, in order.
func (s *FeedbackCategoryService) Active(ctx context.Context) ([]model.FeedbackCategory, error) {
return s.repo.List(ctx, false)
}
// All is the admin's view: archived ones too, with usage counts.
func (s *FeedbackCategoryService) All(ctx context.Context) ([]model.FeedbackCategory, error) {
return s.repo.List(ctx, true)
}
func cleanCategory(label, description string) (string, string, error) {
label = strings.TrimSpace(label)
description = strings.TrimSpace(description)
if label == "" || len([]rune(label)) > maxCategoryLabel {
return "", "", ErrCategoryLabel
}
if len([]rune(description)) > maxCategoryDescription {
return "", "", ErrCategoryDescription
}
return label, description, nil
}
// Create adds a category to the end of the form. Its key is derived from
// the name ("Audio problem" → audio_problem) and fixed from then on, so it
// can be renamed freely later without touching past feedback.
func (s *FeedbackCategoryService) Create(ctx context.Context, label, description string) (*model.FeedbackCategory, error) {
label, description, err := cleanCategory(label, description)
if err != nil {
return nil, err
}
base := strings.Trim(categoryNonSlug.ReplaceAllString(strings.ToLower(label), "_"), "_")
if base == "" {
base = "category"
}
if len(base) > 40 {
base = strings.TrimRight(base[:40], "_")
}
key := base
for i := 2; ; i++ {
taken, err := s.repo.KeyTaken(ctx, key)
if err != nil {
return nil, err
}
if !taken {
break
}
key = fmt.Sprintf("%s_%d", base, i)
}
return s.repo.Create(ctx, key, label, description)
}
// Update renames, rewords, archives or restores a category. The last active
// category can't be archived — the form would have nothing to offer.
func (s *FeedbackCategoryService) Update(ctx context.Context, key, label, description string, archived bool) error {
label, description, err := cleanCategory(label, description)
if err != nil {
return err
}
if archived {
if err := s.guardLastActive(ctx, key); err != nil {
return err
}
}
return s.repo.Update(ctx, key, label, description, archived)
}
// Delete removes a category nothing is filed under (otherwise: archive it).
func (s *FeedbackCategoryService) Delete(ctx context.Context, key string) error {
if err := s.guardLastActive(ctx, key); err != nil {
return err
}
return s.repo.Delete(ctx, key)
}
// Reorder sets the form's order.
func (s *FeedbackCategoryService) Reorder(ctx context.Context, keys []string) error {
return s.repo.Reorder(ctx, keys)
}
func (s *FeedbackCategoryService) guardLastActive(ctx context.Context, key string) error {
active, err := s.repo.List(ctx, false)
if err != nil {
return err
}
if len(active) == 1 && active[0].Key == key {
return ErrCategoryLastActive
}
return nil
}
@@ -0,0 +1,106 @@
package service
import (
"context"
"errors"
"testing"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
// Categories are managed from the admin: a new one is offered on the form
// and accepts feedback; renaming relabels past feedback; archiving stops new
// submissions but keeps the label; in-use categories can't be deleted.
func TestFeedbackCategories(t *testing.T) {
_, _, pg := testChatService(t)
ctx := context.Background()
repo := repository.NewFeedbackCategoryRepo(pg)
cats := NewFeedbackCategoryService(repo)
fb := NewFeedbackService(repository.NewFeedbackRepo(pg), repo, nil, nil, zap.NewNop())
user := chatUser(t, pg, "Feedback")
t.Cleanup(func() {
bg := context.Background()
_, _ = pg.Pool.Exec(bg, `DELETE FROM feedback WHERE user_id = $1`, user)
_, _ = pg.Pool.Exec(bg, `DELETE FROM feedback_categories WHERE key LIKE 'zz_qa_audio%'`)
})
// The seeded categories production already uses are all on the form.
active, err := cats.Active(ctx)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
for _, c := range active {
seen[c.Key] = true
}
for _, k := range []string{"general", "bug", "feature", "other"} {
if !seen[k] {
t.Errorf("seeded category %q missing from the form", k)
}
}
// Keys come from the name, and never collide.
c1, err := cats.Create(ctx, "ZZ QA Audio problem", "Playback trouble")
if err != nil {
t.Fatal(err)
}
c2, err := cats.Create(ctx, "ZZ QA audio problem!", "")
if err != nil {
t.Fatal(err)
}
if c1.Key != "zz_qa_audio_problem" || c2.Key != "zz_qa_audio_problem_2" {
t.Errorf("keys = %q, %q", c1.Key, c2.Key)
}
if _, err := cats.Create(ctx, " ", ""); !errors.Is(err, ErrCategoryLabel) {
t.Errorf("blank name: %v, want ErrCategoryLabel", err)
}
// New categories take feedback; unknown ones don't.
if _, err := fb.Submit(ctx, &user, c1.Key, "the audio stutters", nil); err != nil {
t.Fatalf("submit in a new category: %v", err)
}
if _, err := fb.Submit(ctx, &user, "no_such_category", "x", nil); !errors.Is(err, ErrFeedbackCategory) {
t.Errorf("unknown category: %v, want ErrFeedbackCategory", err)
}
// Renaming relabels what's already been filed.
if err := cats.Update(ctx, c1.Key, "ZZ QA Audio & playback", "Playback trouble", false); err != nil {
t.Fatal(err)
}
mine, _ := fb.ListForUser(ctx, user, 10)
if len(mine) != 1 || mine[0].CategoryLabel != "ZZ QA Audio & playback" || mine[0].Category != c1.Key {
t.Errorf("past feedback after rename = %+v", mine)
}
// Archived: off the form, refuses new feedback, keeps its label.
if err := cats.Update(ctx, c1.Key, "ZZ QA Audio & playback", "", true); err != nil {
t.Fatal(err)
}
if _, err := fb.Submit(ctx, &user, c1.Key, "again", nil); !errors.Is(err, ErrFeedbackCategory) {
t.Errorf("submit to an archived category: %v, want ErrFeedbackCategory", err)
}
active, _ = cats.Active(ctx)
for _, c := range active {
if c.Key == c1.Key {
t.Error("archived category still on the form")
}
}
if mine, _ := fb.ListForUser(ctx, user, 10); mine[0].CategoryLabel != "ZZ QA Audio & playback" {
t.Error("archiving lost the label on past feedback")
}
all, _ := cats.All(ctx)
for _, c := range all {
if c.Key == c1.Key && (c.ArchivedAt == nil || c.UsageCount != 1) {
t.Errorf("admin view of archived category = %+v", c)
}
}
// In use → can't be deleted; unused → can.
if err := cats.Delete(ctx, c1.Key); !errors.Is(err, repository.ErrFeedbackCategoryInUse) {
t.Errorf("deleting an in-use category: %v, want ErrFeedbackCategoryInUse", err)
}
if err := cats.Delete(ctx, c2.Key); err != nil {
t.Errorf("deleting an unused category: %v", err)
}
}
+40 -26
View File
@@ -21,24 +21,23 @@ import (
)
type FeedbackService struct {
repo *repository.FeedbackRepo
mail mailer.Mailer
admins []string
logger *zap.Logger
repo *repository.FeedbackRepo
// categories is the admin-managed list a submission must belong to.
categories *repository.FeedbackCategoryRepo
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}
func NewFeedbackService(repo *repository.FeedbackRepo, categories *repository.FeedbackCategoryRepo, mail mailer.Mailer, admins []string, logger *zap.Logger) *FeedbackService {
return &FeedbackService{repo: repo, categories: categories, 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,
}
// ErrFeedbackCategory: the submission names a category that doesn't exist
// or has been archived in the admin panel.
var ErrFeedbackCategory = errors.New("invalid category")
// Submit persists a feedback row and fires the admin email.
//
@@ -49,8 +48,10 @@ var feedbackCategories = map[string]bool{
// 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")
if ok, err := s.categories.IsActive(ctx, category); err != nil {
return nil, err
} else if !ok {
return nil, ErrFeedbackCategory
}
message = strings.TrimSpace(message)
if message == "" {
@@ -76,22 +77,27 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category,
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))
}
// Hydrate the joined author fields and category label for the email
// template. Failing the lookup isn't worth failing the request — we fall
// back to "anonymous" and the category key rather than 500ing.
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))
f.CategoryLabel = f.Category
}
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.
@@ -216,7 +222,7 @@ func (s *FeedbackService) notifyAdmins(f *model.Feedback) {
s.logger.Warn("render feedback email failed", zap.Error(err))
return
}
subject := fmt.Sprintf("[Feedback · %s] %s", f.Category, summarize(f.Message, 60))
subject := fmt.Sprintf("[Feedback · %s] %s", categoryLabel(f), 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",
@@ -257,7 +263,7 @@ func renderFeedbackEmail(f *model.Feedback) (string, error) {
}
var buf bytes.Buffer
err := feedbackEmailTpl.Execute(&buf, map[string]any{
"Category": f.Category,
"Category": categoryLabel(f),
"Message": f.Message,
"From": from,
"CreatedAt": f.CreatedAt.Format("2006-01-02 15:04:05 MST"),
@@ -354,3 +360,11 @@ func renderFeedbackResolvedEmail(f *model.Feedback) (string, error) {
}
return buf.String(), nil
}
// categoryLabel is the category's display name, or its key if unknown.
func categoryLabel(f *model.Feedback) string {
if f.CategoryLabel != "" {
return f.CategoryLabel
}
return f.Category
}
+260
View File
@@ -0,0 +1,260 @@
package service
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
func testRewardRepo(t *testing.T) (*repository.RewardRepo, *repository.Postgres) {
t.Helper()
uri := os.Getenv("POSTGRES_URI")
if uri == "" {
_ = godotenv.Load(filepath.Join("..", "..", ".env"))
uri = os.Getenv("POSTGRES_URI")
}
if uri == "" {
t.Skip("POSTGRES_URI not set")
}
pg, err := repository.NewPostgres(context.Background(), uri, zap.NewNop())
if err != nil {
t.Skipf("postgres unreachable: %v", err)
}
t.Cleanup(pg.Close)
return repository.NewRewardRepo(pg), pg
}
func giftUser(t *testing.T, pg *repository.Postgres) string {
t.Helper()
id := uuid.NewString()
suffix := id[:8]
if _, err := pg.Pool.Exec(context.Background(), `
INSERT INTO users (id, email, password_hash, name, handle)
VALUES ($1, $2, 'x', 'Gift User', $3)`,
id, "gift-"+suffix+"@example.invalid", "gift_"+suffix); err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
func giftReward(t *testing.T, pg *repository.Postgres) string {
t.Helper()
key := "gift_" + uuid.NewString()[:8]
if _, err := pg.Pool.Exec(context.Background(), `
INSERT INTO sadhna_rewards (key, name, image_url)
VALUES ($1, 'Occasional Gift', 'https://example.invalid/g.png')`, key); err != nil {
t.Fatalf("create reward: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM sadhna_rewards WHERE key = $1`, key)
})
return key
}
// A gift arrives wrapped, opens once, and lands in the vault — and re-gifting
// must not duplicate it.
func TestGift_ArrivesWrappedAndOpensOnce(t *testing.T) {
rewards, pg := testRewardRepo(t)
ctx := context.Background()
user := giftUser(t, pg)
key := giftReward(t, pg)
reward, err := rewards.ByKey(ctx, key)
if err != nil {
t.Fatal(err)
}
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
t.Fatal(err)
}
find := func() (found bool, opened bool) {
grants, err := rewards.GrantsByUser(ctx, user)
if err != nil {
t.Fatal(err)
}
for _, g := range grants {
if g.Reward.Key == key {
if g.Source != "gift" {
t.Errorf("source = %q, want gift", g.Source)
}
if g.Milestone != nil {
t.Errorf("milestone = %v, want nil — a gift isn't a streak day", *g.Milestone)
}
return true, g.Opened
}
}
return false, false
}
found, opened := find()
if !found {
t.Fatal("the gift did not reach an existing account")
}
if opened {
t.Error("a new gift should arrive wrapped")
}
// It must NOT hijack the streak confetti — that stays for streak rewards.
if pending, _ := rewards.PendingGrant(ctx, user); pending != nil {
t.Errorf("a gift appeared as a pending streak celebration: %+v", pending)
}
if err := rewards.OpenGift(ctx, user, key); err != nil {
t.Fatal(err)
}
if _, opened = find(); !opened {
t.Error("opening the gift did not un-blur it")
}
// Opening twice is harmless.
if err := rewards.OpenGift(ctx, user, key); err != nil {
t.Errorf("second open should be a no-op: %v", err)
}
// Re-gifting tops up newcomers without duplicating anyone's tile.
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
t.Fatal(err)
}
grants, _ := rewards.GrantsByUser(ctx, user)
n := 0
for _, g := range grants {
if g.Reward.Key == key {
n++
}
}
if n != 1 {
t.Errorf("holdings of the gift = %d, want 1", n)
}
// ...and it must not silently re-wrap what was already opened.
if _, opened = find(); !opened {
t.Error("re-gifting re-wrapped an already opened gift")
}
}
// Deleting a custom gift must clear it from every gallery it reached, and must
// refuse to touch the seeded catalog — a seeded reward would return on the
// next Seed run, after having been ripped out of people's galleries.
func TestGift_DeleteRemovesItFromEveryGallery(t *testing.T) {
rewards, pg := testRewardRepo(t)
ctx := context.Background()
user := giftUser(t, pg)
// A custom reward, as the admin panel creates them.
key := "custom_probe_" + uuid.NewString()[:8]
if _, err := pg.Pool.Exec(ctx, `
INSERT INTO sadhna_rewards (key, name, image_url)
VALUES ($1, 'Probe Gift', 'https://example.invalid/p.png')`, key); err != nil {
t.Fatal(err)
}
reward, err := rewards.ByKey(ctx, key)
if err != nil {
t.Fatal(err)
}
if _, err := rewards.GrantGiftToEveryone(ctx, reward.ID); err != nil {
t.Fatal(err)
}
held := func() int {
grants, err := rewards.GrantsByUser(ctx, user)
if err != nil {
t.Fatal(err)
}
n := 0
for _, g := range grants {
if g.Reward.Key == key {
n++
}
}
return n
}
if held() != 1 {
t.Fatalf("setup: the gift did not reach the gallery")
}
if err := rewards.DeleteCustom(ctx, key); err != nil {
t.Fatal(err)
}
if held() != 0 {
t.Error("deleting the reward left it in a devotee's gallery")
}
if _, err := rewards.ByKey(ctx, key); !errors.Is(err, repository.ErrRewardNotFound) {
t.Errorf("reward still in the catalog: %v", err)
}
// Seeded rewards are off limits.
var seeded string
if err := pg.Pool.QueryRow(ctx,
`SELECT key FROM sadhna_rewards WHERE key NOT LIKE 'custom\_%' LIMIT 1`,
).Scan(&seeded); err != nil {
t.Skip("no seeded rewards to test against")
}
if err := rewards.DeleteCustom(ctx, seeded); !errors.Is(err, repository.ErrRewardNotFound) {
t.Errorf("a seeded reward was deletable: %v", err)
}
if _, err := rewards.ByKey(ctx, seeded); err != nil {
t.Errorf("the seeded reward was removed: %v", err)
}
}
// A suspended account must not be discoverable. One query backs the people
// search page, the mention autocomplete and the chat member pickers, so a leak
// here surfaces in all three.
func TestBannedUsers_AreNotDiscoverable(t *testing.T) {
_, pg := testRewardRepo(t)
ctx := context.Background()
users := repository.NewUserRepo(pg)
// A distinctive name so the search can't match anyone else.
marker := "Zzbanprobe" + uuid.NewString()[:6]
active := giftUser(t, pg)
banned := giftUser(t, pg)
for id, name := range map[string]string{active: marker + " Active", banned: marker + " Banned"} {
if _, err := pg.Pool.Exec(ctx, `UPDATE users SET name = $2 WHERE id = $1`, id, name); err != nil {
t.Fatal(err)
}
}
if _, err := pg.Pool.Exec(ctx,
`UPDATE users SET banned_at = now() WHERE id = $1`, banned); err != nil {
t.Fatal(err)
}
found, total, err := users.Search(ctx, marker, 20, 0)
if err != nil {
t.Fatal(err)
}
ids := map[string]bool{}
for _, u := range found {
ids[u.ID] = true
}
if !ids[active] {
t.Error("the active account is missing from search")
}
if ids[banned] {
t.Error("a suspended account is searchable — it can be @mentioned or added to a group")
}
// The count backs pagination, so it has to agree with the page.
if total != len(found) {
t.Errorf("total = %d but page returned %d — count and page WHERE disagree", total, len(found))
}
// And the welcome strip, which is the most prominent placement there is.
recent, err := users.RecentlyJoined(ctx, active, time.Now().Add(-24*time.Hour), 20)
if err != nil {
t.Fatal(err)
}
for _, u := range recent {
if u.ID == banned {
t.Error("a suspended account appears in the new-users strip")
}
}
}

Some files were not shown because too many files have changed in this diff Show More