96 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
Krispire Admin efa1eaf23b feat: social graph features 2026-06-04 19:29:48 +05:30
Krispire Admin d9678ebb3b feat: add feedback acknowledgement 2026-06-04 01:55:39 +05:30
Krispire Admin e9a96f1ff9 feat: add feedback resolver 2026-06-03 23:43:17 +05:30
Krispire Admin 61b91c473b feat: post reshare enhancements 2026-06-03 14:51:59 +05:30
Krispire Admin 7f5b0fee11 feat: post reshare enhancements 2026-06-03 14:41:15 +05:30
Krispire Admin 3f6b7b6636 feat: account connect 2026-06-02 22:54:41 +05:30
Krispire Admin cf3a49458a feat: password reset 2026-06-02 18:37:08 +05:30
Krispire Admin ac95a857bd fix: facebook auth 2026-06-02 16:42:22 +05:30
Krispire Admin b895ceaccf fix: facebook auth 2026-06-02 16:14:06 +05:30
Krispire Admin 59b3687e43 enhance oauth for edge cases 2026-06-02 15:21:32 +05:30
Krispire Admin 0a348593b1 feat: fb and google oauth 2026-06-02 12:14:11 +05:30
Krispire Admin 72f4e6527c feat: add stats page 2026-05-31 14:54:15 +05:30
Krispire Admin d3ad443fde feat: add user search 2026-05-31 10:53:37 +05:30
Krispire Admin 0969887008 feat: share post to other apps 2026-05-31 08:05:02 +05:30
Krispire Admin 05b61cef09 add reading settings sync 2026-05-31 07:07:36 +05:30
Krispire Admin 430747536d fix: collectino post background 2026-05-31 00:32:31 +05:30
Krispire Admin aecdd1030a feat: add user centric features 2026-05-30 23:43:27 +05:30
Krispire Admin 97422c1ef5 add auth 2026-05-30 02:33:57 +05:30
256 changed files with 36562 additions and 186 deletions
+53 -11
View File
@@ -9,19 +9,38 @@ MONGO_DB=spsays
# PostgreSQL (users, relations)
POSTGRES_URI=postgres://user:pass@localhost:5432/spsays?sslmode=disable
# Meilisearch
MEILISEARCH_HOST=http://localhost:7700
MEILISEARCH_API_KEY=
# Typesense
TYPESENSE_URL=https://search.prabhupadasays.org
TYPESENSE_API_KEY=
# DragonflyDB / Redis
REDIS_URI=redis://localhost:6379
# S3
S3_REGION=
S3_BUCKET=
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_ENDPOINT=
# Cloudflare R2 (avatar uploads via presigned URLs)
# R2 endpoint is auto-built as https://<account_id>.r2.cloudflarestorage.com
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
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=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM="Prabhupāda Says <noreply@prabhupadasays.org>"
# Comma-separated recipients for /feedback admin notifications. Empty
# disables the email fan-out (feedback rows still persist and show on
# the /stats page either way).
FEEDBACK_ADMIN_EMAILS=
# Push Notifications
FCM_CREDENTIALS_FILE=
@@ -32,7 +51,30 @@ APNS_BUNDLE_ID=
# JWT
JWT_SECRET=change-me-in-production
JWT_EXPIRATION=24h
JWT_EXPIRATION=168h
# Cookie domain — empty in dev (uses request host), ".prabhupadasays.org" in prod
COOKIE_DOMAIN=
COOKIE_SECURE=false
# Frontend
# 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=
# 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
}
+19 -3
View File
@@ -3,18 +3,35 @@ module github.com/prabhupadasays/spsays-bff
go 1.25.0
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/aws/aws-sdk-go-v2 v1.41.9
github.com/aws/aws-sdk-go-v2/credentials v1.19.19
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2
github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.9.2
github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.19.0
github.com/typesense/typesense-go/v2 v2.0.0
go.mongodb.org/mongo-driver/v2 v2.2.1
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.52.0
golang.org/x/oauth2 v0.36.0
)
require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 // indirect
github.com/aws/smithy-go v1.26.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.5.0 // indirect
@@ -30,7 +47,6 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
+81 -8
View File
@@ -1,6 +1,34 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
@@ -15,6 +43,9 @@ 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=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -79,34 +110,76 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+152 -20
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
@@ -28,33 +30,100 @@ type Config struct {
// DragonflyDB / Redis
RedisURI string
// AWS S3
S3Region string
S3Bucket string
S3AccessKey string
S3SecretKey string
S3Endpoint string // optional — for S3-compatible services (MinIO, etc.)
// Cloudflare R2 (S3-compatible) — for avatar uploads via presigned URLs.
R2AccountID string
R2AccessKeyID string
R2SecretAccessKey string
R2Bucket string
R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL
// Push Notifications
FCMCredentialsFile string // path to Firebase service account JSON
APNsKeyFile string // path to APNs auth key
// R2PrivateBucket is a SEPARATE bucket with no public access, used for
// objects that must never be fetchable by URL alone (DM attachments).
// A prefix inside R2Bucket would not do: R2 public access is per-bucket,
// so anything in that bucket is world-readable. Empty disables the
// private-media endpoints (they return 503) — the BFF still boots.
R2PrivateBucket string
// SMTP — used for password-reset, email-verification, and
// /feedback admin-notification mails.
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
SMTPFrom string // "Prabhupāda Says <noreply@prabhupadasays.org>"
// Comma-separated FEEDBACK_ADMIN_EMAILS — recipients for the
// /feedback notification fan-out. Empty disables the email send
// (the DB row is still written and visible via /stats).
FeedbackAdminEmails []string
// Push Notifications (existing — unchanged).
FCMCredentialsFile string
APNsKeyFile string
APNsKeyID string
APNsTeamID string
APNsBundleID string
// JWT Auth
JWTSecret string
JWTExpiration string // e.g., "24h"
JWTExpiration time.Duration // parsed at load time
CookieDomain string // empty in dev; ".prabhupadasays.org" in prod
CookieSecure bool // true in production
// Frontend
FrontendURL string // for CORS
FrontendURL string // for CORS + reset/verify links in emails
// AdminURL is the admin panel's origin. When set it's added to the CORS
// allow-list (the admin app is a separate origin from FrontendURL). Empty
// disables admin-origin CORS (only the main frontend is allowed).
AdminURL string
// RevalidateSecret authenticates BFF→frontend on-demand cache
// revalidation pings (POST /api/revalidate). Empty disables the pings.
RevalidateSecret string
// BFF public URL — the externally-reachable origin of this server. Used
// to build the OAuth redirect_uri the provider sees (must match the
// allow-list configured in the provider's console). In dev this is
// usually http://localhost:8080.
BFFPublicURL string
// OAuth providers — empty creds disable the corresponding provider.
GoogleOAuthClientID string
GoogleOAuthClientSecret string
FacebookOAuthClientID string
FacebookOAuthClientSecret string
// Soketi (Pusher-compatible WebSocket server) — used to push realtime
// notifications. Empty App credentials disable realtime delivery (the
// DB row + email still happen). SoketiHost is the HTTP-API origin the
// BFF triggers events against (e.g. "ws.prabhupadasays.org"); the
// frontend connects to the same host over WSS.
SoketiAppID string
SoketiAppKey string
SoketiAppSecret string
SoketiHost string
SoketiPort int
SoketiUseTLS bool
// Web Push (VAPID). Empty keys disable push delivery (other channels
// still run). VAPIDSubject is a mailto: or https: contact URL required
// by the spec. The public key is also served to the browser to
// subscribe.
VAPIDPublicKey string
VAPIDPrivateKey string
VAPIDSubject string
}
// Load reads configuration from environment variables (with .env fallback).
func Load() (*Config, error) {
// Load .env file if present (ignored in production)
_ = godotenv.Load()
jwtExp, err := time.ParseDuration(getEnv("JWT_EXPIRATION", "168h"))
if err != nil {
return nil, fmt.Errorf("invalid JWT_EXPIRATION: %w", err)
}
cfg := &Config{
Env: getEnv("APP_ENV", "development"),
Port: getEnvInt("PORT", 8080),
@@ -69,11 +138,20 @@ func Load() (*Config, error) {
RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"),
S3Region: getEnv("S3_REGION", ""),
S3Bucket: getEnv("S3_BUCKET", ""),
S3AccessKey: getEnv("S3_ACCESS_KEY", ""),
S3SecretKey: getEnv("S3_SECRET_KEY", ""),
S3Endpoint: getEnv("S3_ENDPOINT", ""),
R2AccountID: getEnv("R2_ACCOUNT_ID", ""),
R2AccessKeyID: getEnv("R2_ACCESS_KEY_ID", ""),
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),
SMTPUsername: getEnv("SMTP_USERNAME", ""),
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"),
FeedbackAdminEmails: parseList(getEnv("FEEDBACK_ADMIN_EMAILS", "")),
FCMCredentialsFile: getEnv("FCM_CREDENTIALS_FILE", ""),
APNsKeyFile: getEnv("APNS_KEY_FILE", ""),
@@ -82,15 +160,43 @@ func Load() (*Config, error) {
APNsBundleID: getEnv("APNS_BUNDLE_ID", ""),
JWTSecret: getEnv("JWT_SECRET", ""),
JWTExpiration: getEnv("JWT_EXPIRATION", "24h"),
JWTExpiration: jwtExp,
CookieDomain: getEnv("COOKIE_DOMAIN", ""),
CookieSecure: getEnvBool("COOKIE_SECURE", false),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
AdminURL: getEnv("ADMIN_URL", ""),
RevalidateSecret: getEnv("REVALIDATE_SECRET", ""),
BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"),
GoogleOAuthClientID: getEnv("GOOGLE_OAUTH_CLIENT_ID", ""),
GoogleOAuthClientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET", ""),
FacebookOAuthClientID: getEnv("FACEBOOK_OAUTH_CLIENT_ID", ""),
FacebookOAuthClientSecret: getEnv("FACEBOOK_OAUTH_CLIENT_SECRET", ""),
SoketiAppID: getEnv("SOKETI_APP_ID", ""),
SoketiAppKey: getEnv("SOKETI_APP_KEY", ""),
SoketiAppSecret: getEnv("SOKETI_APP_SECRET", ""),
SoketiHost: getEnv("SOKETI_HOST", "ws.prabhupadasays.org"),
SoketiPort: getEnvInt("SOKETI_PORT", 443),
SoketiUseTLS: getEnvBool("SOKETI_USE_TLS", true),
VAPIDPublicKey: getEnv("VAPID_PUBLIC_KEY", ""),
VAPIDPrivateKey: getEnv("VAPID_PRIVATE_KEY", ""),
VAPIDSubject: getEnv("VAPID_SUBJECT", "mailto:admin@prabhupadasays.org"),
}
// Validate required fields
if cfg.MongoURI == "" {
return nil, fmt.Errorf("MONGO_URI is required")
}
if cfg.Env == "production" && cfg.JWTSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required in production")
}
if cfg.JWTSecret == "" {
// Dev fallback so the server can boot without secrets configured.
cfg.JWTSecret = "dev-only-jwt-secret-do-not-use-in-production"
}
return cfg, nil
}
@@ -110,3 +216,29 @@ func getEnvInt(key string, fallback int) int {
}
return fallback
}
func getEnvBool(key string, fallback bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}
// parseList splits a comma-separated env value, trimming whitespace
// and discarding empty entries. Returns nil for empty input so
// callers can range over it safely without a length check.
func parseList(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+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)
}
+383
View File
@@ -0,0 +1,383 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/geoip"
"github.com/prabhupadasays/spsays-bff/pkg/response"
"github.com/prabhupadasays/spsays-bff/pkg/validator"
)
// AuthHandler exposes the auth-related HTTP endpoints.
//
// Every handler is a 5-line shim: parse → validate → service → render.
// All branching (already exists? wrong password? expired link?) lives in
// AuthService and surfaces here only as sentinel errors mapped to status
// codes via mapAuthErr.
type AuthHandler struct {
svc *service.AuthService
cookies auth.CookieOptions
}
func NewAuthHandler(svc *service.AuthService, cookies auth.CookieOptions) *AuthHandler {
return &AuthHandler{svc: svc, cookies: cookies}
}
// --- request shapes ---
type signUpReq struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
type logInReq struct {
Email string `json:"email"`
Password string `json:"password"`
}
type forgotReq struct {
Email string `json:"email"`
}
type resetReq struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
type verifyReq struct {
Token string `json:"token"`
}
type verifyLoginOTPReq struct {
Email string `json:"email"`
Password string `json:"password"`
OTP string `json:"otp"`
}
type setEmailReq struct {
Email string `json:"email"`
}
type changePasswordReq struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// --- handlers ---
// SignUp creates a user, plants the session cookie, returns the user.
func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
var req signUpReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "email", req.Email)
validator.Email(v, "email", req.Email)
validator.Required(v, "password", req.Password)
validator.MinLength(v, "password", req.Password, 8)
validator.Required(v, "name", req.Name)
validator.MaxLength(v, "name", req.Name, 120)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
res, err := h.svc.SignUp(r.Context(), req.Email, req.Password, req.Name)
if err != nil {
h.renderAuthErr(w, err)
return
}
h.captureCountry(r, res.User.ID)
auth.Set(w, res.Token, res.Expires, h.cookies)
response.JSON(w, http.StatusCreated, res.User)
}
// captureCountry records the new account's country off the critical path.
// Cloudflare's CF-IPCountry header is preferred (no network); when absent
// — as in the current DNS-only setup — we fall back to a GeoIP lookup on
// the client IP. The header read + IP extraction happen synchronously
// (they need the live request), then the network lookup + DB write run in
// a goroutine so signup latency is untouched. Any failure is silent: the
// account simply keeps country = NULL until the user sets it themselves.
func (h *AuthHandler) captureCountry(r *http.Request, userID string) {
cfCountry := geoip.CountryFromCloudflare(r)
ip := geoip.ClientIP(r)
go func() {
cc := cfCountry
if cc == "" {
lookupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
cc = geoip.LookupCountryByIP(lookupCtx, ip)
cancel()
}
if cc == "" {
return
}
writeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
h.svc.CaptureCountry(writeCtx, userID, cc)
}()
}
// LogIn authenticates an existing user. The response is one of:
//
// - 200 OK with the User JSON — credentials verified, session cookie set,
// frontend treats this as fully signed in.
// - 202 Accepted with `{ "otp_required": true }` — credentials verified
// but the account has been unverified for over 30 days. An OTP has been
// emailed; frontend must collect it and call VerifyLoginOTP next. No
// session cookie is set on this branch.
func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
var req logInReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "email", req.Email)
validator.Required(v, "password", req.Password)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
res, err := h.svc.LogIn(r.Context(), req.Email, req.Password)
if err != nil {
h.renderAuthErr(w, err)
return
}
if res.OTPRequired {
response.JSON(w, http.StatusAccepted, map[string]any{
"otp_required": true,
})
return
}
auth.Set(w, res.Auth.Token, res.Auth.Expires, h.cookies)
response.JSON(w, http.StatusOK, res.Auth.User)
}
// VerifyLoginOTP completes a login that was gated by the stale-unverified
// OTP path. Re-checks the password (the OTP alone isn't enough — an
// attacker who steals the OTP email still needs the password), validates
// the OTP, flips email_verified_at to now(), and sets the session cookie.
func (h *AuthHandler) VerifyLoginOTP(w http.ResponseWriter, r *http.Request) {
var req verifyLoginOTPReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "email", req.Email)
validator.Required(v, "password", req.Password)
validator.Required(v, "otp", req.OTP)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
res, err := h.svc.VerifyLoginOTP(r.Context(), req.Email, req.Password, req.OTP)
if err != nil {
h.renderAuthErr(w, err)
return
}
auth.Set(w, res.Token, res.Expires, h.cookies)
response.JSON(w, http.StatusOK, res.User)
}
// LogOut just clears the cookie. JWTs remain technically valid until expiry,
// but with the cookie gone the browser can't present them.
func (h *AuthHandler) LogOut(w http.ResponseWriter, r *http.Request) {
auth.Clear(w, h.cookies)
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// Me returns the authenticated user. Protected by middleware.RequireAuth.
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
tv := middleware.TokenVersion(r.Context())
u, err := h.svc.Me(r.Context(), uid, tv)
if err != nil {
response.Error(w, http.StatusUnauthorized, "not authenticated")
return
}
response.JSON(w, http.StatusOK, u)
}
// ForgotPassword emails a reset link if the address exists. We always return
// 200 to avoid revealing whether the email is registered.
func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
var req forgotReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "email", req.Email)
validator.Email(v, "email", req.Email)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
if err := h.svc.RequestPasswordReset(r.Context(), req.Email); err != nil {
response.Error(w, http.StatusInternalServerError, "could not process request")
return
}
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ResetPassword consumes a reset token and sets a new password.
func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
var req resetReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "token", req.Token)
validator.Required(v, "new_password", req.NewPassword)
validator.MinLength(v, "new_password", req.NewPassword, 8)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
if err := h.svc.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil {
h.renderAuthErr(w, err)
return
}
// Force the user to log in again after a reset (their token_version is now stale).
auth.Clear(w, h.cookies)
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ChangePassword — protected — replaces the caller's password after
// verifying the current one. The new password bumps token_version, which
// invalidates every previously-issued JWT for this user. We immediately
// re-issue a fresh session cookie so the caller's current device stays
// logged in; every other device gets signed out on next request.
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req changePasswordReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
// current_password is optional — accounts with no password on file
// (OAuth-only signups setting their first one) skip it. The service
// enforces verification when a hash exists.
v := validator.New()
validator.Required(v, "new_password", req.NewPassword)
validator.MinLength(v, "new_password", req.NewPassword, 8)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
res, err := h.svc.ChangePassword(r.Context(), uid, req.CurrentPassword, req.NewPassword)
if err != nil {
h.renderAuthErr(w, err)
return
}
auth.Set(w, res.Token, res.Expires, h.cookies)
response.JSON(w, http.StatusOK, res.User)
}
// VerifyEmail flips email_verified_at after the user clicks the link.
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
var req verifyReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.Token == "" {
response.Error(w, http.StatusBadRequest, "token is required")
return
}
if err := h.svc.VerifyEmail(r.Context(), req.Token); err != nil {
h.renderAuthErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// SetEmail — protected — sets (or replaces) the caller's email address.
// Used by OAuth-signup users who landed without one, and by anyone wanting
// to change their address. The new value is stored as unverified and a
// verification link is mailed out immediately.
func (h *AuthHandler) SetEmail(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req setEmailReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "email", req.Email)
validator.Email(v, "email", req.Email)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
u, err := h.svc.SetEmail(r.Context(), uid, req.Email)
if err != nil {
h.renderAuthErr(w, err)
return
}
response.JSON(w, http.StatusOK, u)
}
// LinkedProviders — protected — returns the OAuth provider names already
// linked to the caller's account. Settings UI uses this to flip each
// social-connect row between "Connect" and "Connected".
func (h *AuthHandler) LinkedProviders(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
providers, err := h.svc.LinkedProviders(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load linked providers")
return
}
response.JSON(w, http.StatusOK, map[string]any{"providers": providers})
}
// ResendVerification — protected — refires the verification email for the
// currently-logged-in user.
func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.ResendVerification(r.Context(), uid); err != nil {
h.renderAuthErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// --- error mapping ---
func (h *AuthHandler) renderAuthErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, service.ErrInvalidCredentials),
errors.Is(err, service.ErrInvalidOrExpiredLink):
response.Error(w, http.StatusUnauthorized, err.Error())
case errors.Is(err, service.ErrEmailAlreadyTaken):
response.Error(w, http.StatusConflict, err.Error())
case errors.Is(err, service.ErrEmailAlreadyVerified),
errors.Is(err, service.ErrWeakPassword):
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) {
+88
View File
@@ -0,0 +1,88 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
"github.com/prabhupadasays/spsays-bff/pkg/validator"
)
// FeedbackHandler exposes POST /api/v1/feedback. The router wraps it
// with OptionalAuth so a session cookie populates user_id when one is
// present, and anonymous callers are still accepted (they have to
// supply contact_email instead).
type FeedbackHandler struct{ svc *service.FeedbackService }
func NewFeedbackHandler(svc *service.FeedbackService) *FeedbackHandler {
return &FeedbackHandler{svc: svc}
}
type submitFeedbackReq struct {
Category string `json:"category"`
Message string `json:"message"`
ContactEmail *string `json:"contact_email,omitempty"`
}
func (h *FeedbackHandler) Submit(w http.ResponseWriter, r *http.Request) {
var req submitFeedbackReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "category", req.Category)
validator.Required(v, "message", req.Message)
validator.MaxLength(v, "message", req.Message, 5000)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
// OptionalAuth seeds user_id when a valid session cookie was
// present; "" means anonymous.
uid := middleware.UserID(r.Context())
var userIDPtr *string
if uid != "" {
userIDPtr = &uid
}
// Soft-validate the contact email for the anonymous path. Authed
// callers' contact_email is ignored downstream, so skip the check.
if userIDPtr == nil && req.ContactEmail != nil && *req.ContactEmail != "" {
ev := validator.New()
validator.Email(ev, "contact_email", *req.ContactEmail)
if !ev.Valid() {
response.Error(w, http.StatusBadRequest, ev.Error())
return
}
}
f, err := h.svc.Submit(r.Context(), userIDPtr, req.Category, req.Message, req.ContactEmail)
if err != nil {
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusCreated, f)
}
// Mine returns the authenticated caller's own feedback submissions,
// newest first — each with its status (acknowledged / resolved) and any
// admin reply. Registered under RequireAuth, so UserID is always set;
// the guard is belt-and-suspenders.
func (h *FeedbackHandler) Mine(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if uid == "" {
response.Error(w, http.StatusUnauthorized, "authentication required")
return
}
items, err := h.svc.ListForUser(r.Context(), uid, 50)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load your feedback")
return
}
response.JSON(w, http.StatusOK, items)
}
+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)
}
+128
View File
@@ -0,0 +1,128 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// NotificationHandler serves the signed-in user's notification feed +
// unread badge count, and marks them read. All routes sit behind
// RequireAuth, so middleware.UserID is always populated.
type NotificationHandler struct{ svc *service.NotificationService }
func NewNotificationHandler(svc *service.NotificationService) *NotificationHandler {
return &NotificationHandler{svc: svc}
}
// List returns the caller's notifications, newest first.
func (h *NotificationHandler) List(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
items, err := h.svc.List(r.Context(), uid, limit, offset)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load notifications")
return
}
response.JSON(w, http.StatusOK, items)
}
// UnreadCount returns {"count": N} for the bell badge.
func (h *NotificationHandler) UnreadCount(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
n, err := h.svc.UnreadCount(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load unread count")
return
}
response.JSON(w, http.StatusOK, map[string]int{"count": n})
}
// MarkAllRead marks every notification read (called when the page opens).
func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.MarkAllRead(r.Context(), uid); err != nil {
response.Error(w, http.StatusInternalServerError, "could not mark notifications read")
return
}
response.NoContent(w)
}
// GetPrefs — GET /notifications/prefs. The resolved Settings matrix:
// {"prefs": {"follows": {"in_app": true, "email": true, "push": true}, …}}.
func (h *NotificationHandler) GetPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
matrix, err := h.svc.PrefMatrix(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load notification preferences")
return
}
response.JSON(w, http.StatusOK, map[string]any{"prefs": matrix})
}
// UpdatePrefs — PUT /notifications/prefs { event, channel, enabled }. Writes
// one matrix cell; returns the full re-resolved matrix so the client can
// render authoritative state.
func (h *NotificationHandler) UpdatePrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req struct {
Event string `json:"event"`
Channel string `json:"channel"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetPref(r.Context(), uid, req.Event, req.Channel, req.Enabled); err != nil {
if errors.Is(err, service.ErrUnknownPref) {
response.Error(w, http.StatusBadRequest, "unknown event or channel")
return
}
response.Error(w, http.StatusInternalServerError, "could not update notification preferences")
return
}
matrix, err := h.svc.PrefMatrix(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load notification preferences")
return
}
response.JSON(w, http.StatusOK, map[string]any{"prefs": matrix})
}
type emailPrefsResp struct {
LikeEmails bool `json:"like_emails"`
}
// GetEmailPrefs — GET /notifications/email-prefs. The caller's email-notification
// preferences (currently just aggregated like emails).
func (h *NotificationHandler) GetEmailPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
enabled, err := h.svc.LikeEmailPref(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load email preferences")
return
}
response.JSON(w, http.StatusOK, emailPrefsResp{LikeEmails: enabled})
}
// UpdateEmailPrefs — PUT /notifications/email-prefs { like_emails }.
func (h *NotificationHandler) UpdateEmailPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req struct {
LikeEmails bool `json:"like_emails"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetLikeEmailPref(r.Context(), uid, req.LikeEmails); err != nil {
response.Error(w, http.StatusInternalServerError, "could not update email preferences")
return
}
response.JSON(w, http.StatusOK, emailPrefsResp{LikeEmails: req.LikeEmails})
}
+309
View File
@@ -0,0 +1,309 @@
package handler
import (
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
"go.uber.org/zap"
)
// stateCookieName is the short-lived cookie that pairs with the state query
// param so callback can verify "this redirect was started by us." Scoped to
// /api/v1/auth/oauth so it never leaks into other handlers.
const stateCookieName = "spsays_oauth_state"
const stateCookieMaxAge = 10 * 60 // 10 minutes — matches stateMaxAge in pkg/oauth/state.go
// OAuthHandler exposes the GET /api/v1/auth/oauth/{provider}/start and
// /callback endpoints. It composes Provider → token exchange → service.SignInWithOAuth.
type OAuthHandler struct {
svc *service.AuthService
cookies auth.CookieOptions
providers oauth.Registry
stateSecret []byte // HMAC key for signing state — derived from JWT secret
bffPublicURL string // e.g. http://localhost:8080 or https://api.prabhupadasays.org
frontendURL string // e.g. http://localhost:3000 or https://prabhupadasays.org
logger *zap.Logger
}
func NewOAuthHandler(
svc *service.AuthService,
cookies auth.CookieOptions,
providers oauth.Registry,
stateSecret []byte,
bffPublicURL, frontendURL string,
logger *zap.Logger,
) *OAuthHandler {
return &OAuthHandler{
svc: svc,
cookies: cookies,
providers: providers,
stateSecret: stateSecret,
bffPublicURL: strings.TrimRight(bffPublicURL, "/"),
frontendURL: strings.TrimRight(frontendURL, "/"),
logger: logger,
}
}
// Start kicks off the OAuth round-trip. It generates a signed state value,
// stores it in a short-lived cookie, and 302s the browser to the provider's
// AuthCodeURL. `next` query param carries the post-login redirect path on
// the frontend (defaults to "/u/me").
func (h *OAuthHandler) Start(w http.ResponseWriter, r *http.Request) {
providerName := chi.URLParam(r, "provider")
prov, err := h.providers.Get(providerName)
if err != nil {
http.Error(w, "unknown provider", http.StatusNotFound)
return
}
next := sanitizeNext(r.URL.Query().Get("next"))
state, err := oauth.EncodeState(h.stateSecret, providerName, next, "")
if err != nil {
h.logger.Error("oauth encode state", zap.Error(err))
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// State cookie matches the auth cookie's SameSite/Secure profile so the
// browser sends it back on the callback redirect. Path-scoped so it
// doesn't ride along on unrelated requests.
http.SetCookie(w, &http.Cookie{
Name: stateCookieName,
Value: state,
Path: "/api/v1/auth/oauth",
Domain: h.cookies.Domain,
MaxAge: stateCookieMaxAge,
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
cfg := prov.Config(h.callbackURL(providerName))
authURL := cfg.AuthCodeURL(state, prov.AuthCodeOptions()...)
http.Redirect(w, r, authURL, http.StatusFound)
}
// Callback finishes the round-trip. Verifies state, exchanges the code,
// fetches the profile, signs the user in, sets the session cookie, and
// redirects back to the frontend `next` path.
func (h *OAuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
providerName := chi.URLParam(r, "provider")
prov, err := h.providers.Get(providerName)
if err != nil {
h.redirectErr(w, r, "/login?oauth_error=unknown_provider")
return
}
// Provider returned an error (user denied, etc.).
if e := r.URL.Query().Get("error"); e != "" {
h.redirectErr(w, r, "/login?oauth_error="+url.QueryEscape(e))
return
}
// State must be present in BOTH cookie and query param, and they must
// match. Either alone is not sufficient (cookie alone → no CSRF binding;
// query alone → an attacker could craft any signed state).
cookie, err := r.Cookie(stateCookieName)
if err != nil {
h.redirectErr(w, r, "/login?oauth_error=missing_state")
return
}
stateQ := r.URL.Query().Get("state")
if stateQ == "" || stateQ != cookie.Value {
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
return
}
// Clear the state cookie regardless of what happens next.
http.SetCookie(w, &http.Cookie{
Name: stateCookieName,
Value: "",
Path: "/api/v1/auth/oauth",
Domain: h.cookies.Domain,
MaxAge: -1,
Expires: time.Unix(0, 0),
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
stateProvider, next, linkUserID, err := oauth.DecodeState(h.stateSecret, stateQ)
if err != nil || stateProvider != providerName {
h.redirectErr(w, r, "/login?oauth_error=invalid_state")
return
}
code := r.URL.Query().Get("code")
if code == "" {
h.redirectErr(w, r, "/login?oauth_error=missing_code")
return
}
cfg := prov.Config(h.callbackURL(providerName))
tok, err := cfg.Exchange(r.Context(), code)
if err != nil {
h.logger.Warn("oauth code exchange failed", zap.String("provider", providerName), zap.Error(err))
h.redirectErr(w, r, "/login?oauth_error=exchange_failed")
return
}
prof, err := prov.FetchProfile(r.Context(), tok)
if err != nil {
h.logger.Warn("oauth fetch profile failed", zap.String("provider", providerName), zap.Error(err))
h.redirectErr(w, r, "/login?oauth_error=profile_failed")
return
}
// State pinned to a user id means the OAuth round-trip was started from
// /link/start (settings page). Dispatch to the link path so we link the
// provider to that user instead of running a fresh sign-in.
if linkUserID != "" {
h.finishLink(w, r, providerName, next, linkUserID, prof)
return
}
res, err := h.svc.SignInWithOAuth(r.Context(), service.OAuthProfile{
Provider: prof.Provider,
ProviderUserID: prof.ProviderUserID,
Email: prof.Email,
EmailVerified: prof.EmailVerified,
Name: prof.Name,
AvatarURL: prof.AvatarURL,
})
if err != nil {
switch {
case errors.Is(err, service.ErrOAuthUnavailable):
h.redirectErr(w, r, "/login?oauth_error=unavailable")
default:
h.logger.Error("oauth sign-in", zap.Error(err))
h.redirectErr(w, r, "/login?oauth_error=server")
}
return
}
auth.Set(w, res.Token, res.Expires, h.cookies)
http.Redirect(w, r, h.frontendURL+next, http.StatusFound)
}
// LinkStart kicks off the OAuth round-trip for an already-authenticated
// user who wants to add a provider to their account (settings → Connect).
// The caller's user id is signed into the state so the callback knows
// which account to link to instead of trying to find one by email.
func (h *OAuthHandler) LinkStart(w http.ResponseWriter, r *http.Request) {
providerName := chi.URLParam(r, "provider")
prov, err := h.providers.Get(providerName)
if err != nil {
http.Error(w, "unknown provider", http.StatusNotFound)
return
}
uid := middleware.UserID(r.Context())
if uid == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next := sanitizeNext(r.URL.Query().Get("next"))
state, err := oauth.EncodeState(h.stateSecret, providerName, next, uid)
if err != nil {
h.logger.Error("oauth encode link state", zap.Error(err))
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: stateCookieName,
Value: state,
Path: "/api/v1/auth/oauth",
Domain: h.cookies.Domain,
MaxAge: stateCookieMaxAge,
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
cfg := prov.Config(h.callbackURL(providerName))
authURL := cfg.AuthCodeURL(state, prov.AuthCodeOptions()...)
http.Redirect(w, r, authURL, http.StatusFound)
}
// finishLink completes the link flow once the shared Callback has decoded
// state and fetched the provider profile. Bounces back to the frontend
// `next` path with `?link=ok|claimed|error` so the settings UI can render
// a meaningful message.
func (h *OAuthHandler) finishLink(
w http.ResponseWriter,
r *http.Request,
providerName, next, linkUserID string,
prof *oauth.Profile,
) {
err := h.svc.LinkOAuthToCurrentUser(r.Context(), linkUserID, service.OAuthProfile{
Provider: prof.Provider,
ProviderUserID: prof.ProviderUserID,
Email: prof.Email,
EmailVerified: prof.EmailVerified,
Name: prof.Name,
AvatarURL: prof.AvatarURL,
})
target := next
switch {
case err == nil:
target = appendQuery(next, "link", providerName+":ok")
case errors.Is(err, service.ErrOAuthAccountClaimed):
target = appendQuery(next, "link", providerName+":claimed")
default:
h.logger.Error("oauth link", zap.String("provider", providerName), zap.Error(err))
target = appendQuery(next, "link", providerName+":error")
}
http.Redirect(w, r, h.frontendURL+target, http.StatusFound)
}
// appendQuery adds a ?key=val (or &key=val) to a same-origin path, preserving
// any existing query string. Path must start with "/" — caller guarantees.
func appendQuery(path, key, value string) string {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
return path + sep + url.QueryEscape(key) + "=" + url.QueryEscape(value)
}
// callbackURL composes the redirect_uri the provider's console must allow-list.
func (h *OAuthHandler) callbackURL(provider string) string {
return h.bffPublicURL + "/api/v1/auth/oauth/" + provider + "/callback"
}
// redirectErr sends the user back to the frontend with a single error query
// param so the login page can render something meaningful. No internal details
// leak through this path.
func (h *OAuthHandler) redirectErr(w http.ResponseWriter, r *http.Request, path string) {
http.Redirect(w, r, h.frontendURL+path, http.StatusFound)
}
// sanitizeNext bounds the post-login redirect to same-origin paths. Anything
// containing a scheme, host, or whitespace is dropped to the default. This is
// the same anti-open-redirect treatment we apply on the share-shortener.
func sanitizeNext(raw string) string {
const def = "/u/me"
if raw == "" {
return def
}
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
return def
}
if strings.ContainsAny(raw, " \t\r\n") {
return def
}
if len(raw) > 512 {
return def
}
return raw
}
+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)
}
+101
View File
@@ -0,0 +1,101 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// RelationshipHandler exposes the follow / block / report actions, all
// scoped to a target user identified by {handle} in the path and a
// signed-in viewer pulled from the auth middleware. Every mutating
// endpoint returns the recomputed RelationshipResult (viewer's state +
// the target's live counts) so the frontend updates its buttons and
// stat line from the single response — except Report, which returns 204.
//
// These routes live under /api/v1/u/{handle}/… in a RequireAuth group;
// the public profile GET stays anonymous-friendly in its own group.
type RelationshipHandler struct{ svc *service.SocialGraphService }
func NewRelationshipHandler(svc *service.SocialGraphService) *RelationshipHandler {
return &RelationshipHandler{svc: svc}
}
// Follow sends a follow REQUEST (pending until the target accepts).
func (h *RelationshipHandler) Follow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.RequestFollow)
}
// Unfollow removes the viewer's edge — stops following or cancels a
// still-pending request.
func (h *RelationshipHandler) Unfollow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.Unfollow)
}
// AcceptFollow accepts the target's pending request to follow the viewer.
func (h *RelationshipHandler) AcceptFollow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.AcceptFollow)
}
// RejectFollow declines the target's pending request.
func (h *RelationshipHandler) RejectFollow(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.RejectFollow)
}
// RemoveFollower drops the target from the viewer's followers.
func (h *RelationshipHandler) RemoveFollower(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.RemoveFollower)
}
// Block blocks the target (and severs follows both ways).
func (h *RelationshipHandler) Block(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.Block)
}
// Unblock removes the viewer's block on the target.
func (h *RelationshipHandler) Unblock(w http.ResponseWriter, r *http.Request) {
h.act(w, r, h.svc.Unblock)
}
type reportReq struct {
Category string `json:"category"`
Reason string `json:"reason"`
}
// Report files a report against the target. Body: {category, reason}.
func (h *RelationshipHandler) Report(w http.ResponseWriter, r *http.Request) {
viewerID := middleware.UserID(r.Context())
handle := chi.URLParam(r, "handle")
var req reportReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.Report(r.Context(), viewerID, handle, req.Category, req.Reason); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// act is the shared shim for every RelationshipResult-returning action:
// pull viewer + handle, run the service method, map errors, return JSON.
func (h *RelationshipHandler) act(
w http.ResponseWriter,
r *http.Request,
fn func(ctx context.Context, viewerID, handle string) (*service.RelationshipResult, error),
) {
viewerID := middleware.UserID(r.Context())
handle := chi.URLParam(r, "handle")
res, err := fn(r.Context(), viewerID, handle)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, res)
}
+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)
}
+79 -26
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.
@@ -147,6 +152,12 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
limit = 100
}
// `exact=1` (or "true") disables typo tolerance + prefix expansion so the
// query only matches literal keywords. Stopword filtering still applies —
// "letter to mukunda" with exact=1 still drops "letter"/"to" so the search
// is effectively the literal word "mukunda" with no fuzzy variants.
exact := parseBoolParam(r.URL.Query().Get("exact"))
ctx := r.Context()
var results []searchResultItem
@@ -154,13 +165,13 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
if searchType == "all" {
// Search all 9 collections
results, totalFound = h.multiSearch(ctx, nil, query, page, limit)
results, totalFound = h.multiSearch(ctx, nil, query, page, limit, exact)
} else if len(requestedTypes) == 1 {
// Single collection — use singleSearch for efficiency
results, totalFound = h.singleSearch(ctx, requestedTypes[0], query, page, limit)
results, totalFound = h.singleSearch(ctx, requestedTypes[0], query, page, limit, exact)
} else {
// Multiple collections — use multiSearch with a subset
results, totalFound = h.multiSearch(ctx, requestedTypes, query, page, limit)
results, totalFound = h.multiSearch(ctx, requestedTypes, query, page, limit, exact)
}
totalPages := int(math.Ceil(float64(totalFound) / float64(limit)))
@@ -181,8 +192,10 @@ func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
// multiSearch searches across all 9 collections using Typesense multi_search,
// merges results by text_match score, and returns a single paginated list.
// multiSearch searches across multiple collections using Typesense multi_search.
// If collections is nil, searches all 9 collections.
func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, query string, page, limit int) ([]searchResultItem, int) {
// If collections is nil, searches all 9 collections. When `exact` is true,
// typo tolerance and prefix expansion are turned off so only literal-keyword
// matches come back.
func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, query string, page, limit int, exact bool) ([]searchResultItem, int) {
if collections == nil {
collections = []string{
"verses", "prose", "letters", "transcriptions", "glossary",
@@ -193,20 +206,30 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
exhaustive := true
prioritizeExact := true
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),
QueryBy: stringPtr(queryBy),
PerPage: &perPage,
Page: &pg,
Prefix: stringPtr("true"),
NumTypos: stringPtr("2"),
Prefix: stringPtr(prefix),
NumTypos: stringPtr(numTypos),
ExhaustiveSearch: &exhaustive,
Stopwords: stringPtr(searchStopwordsSet),
// Boost docs whose tokens are exact matches and appear early in the field
@@ -253,18 +276,22 @@ 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.
func (h *SearchHandler) singleSearch(ctx context.Context, collection, query string, page, limit int) ([]searchResultItem, int) {
func (h *SearchHandler) singleSearch(ctx context.Context, collection, query string, page, limit int, exact bool) ([]searchResultItem, int) {
queryBy, ok := collectionQueryByFields[collection]
if !ok {
return []searchResultItem{}, 0
@@ -273,13 +300,14 @@ func (h *SearchHandler) singleSearch(ctx context.Context, collection, query stri
exhaustive := true
prioritizeExact := true
prioritizeTokenPos := true
prefix, numTypos := fuzzinessKnobs(exact)
params := &api.SearchCollectionParams{
Q: stringPtr(query),
QueryBy: stringPtr(queryBy),
Page: &page,
PerPage: &limit,
Prefix: stringPtr("true"),
NumTypos: stringPtr("2"),
Prefix: stringPtr(prefix),
NumTypos: stringPtr(numTypos),
ExhaustiveSearch: &exhaustive,
Stopwords: stringPtr(searchStopwordsSet),
// Boost docs whose tokens are exact matches and appear early in the field
@@ -326,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,
}
@@ -534,3 +563,27 @@ func parseIntParam(s string, defaultVal int) int {
func stringPtr(s string) *string {
return &s
}
// parseBoolParam returns true for "1", "true", "yes", "on" (case-insensitive).
// Everything else (including the empty string) is false.
func parseBoolParam(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "yes", "on":
return true
}
return false
}
// fuzzinessKnobs maps the high-level `exact` flag to the two Typesense params
// that actually disable fuzzy matching:
//
// exact=false → prefix=true, num_typos=2 (default forgiving search)
// exact=true → prefix=false, num_typos=0 (literal-keyword only)
//
// Stopword filtering is left unchanged in both modes.
func fuzzinessKnobs(exact bool) (prefix, numTypos string) {
if exact {
return "false", "0"
}
return "true", "2"
}
+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})
}
+981
View File
@@ -0,0 +1,981 @@
// Package handler — social endpoints for posts, bookmarks, collections, and
// public profiles. Three small handler structs share this file because each
// is itself just a thin shim over its service. mapSocialErr at the bottom
// centralizes the error→HTTP-status mapping so every endpoint surfaces the
// same shapes for plan-limit / forbidden / not-found.
package handler
import (
"encoding/json"
"errors"
"math"
"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"
"github.com/prabhupadasays/spsays-bff/pkg/response"
"github.com/prabhupadasays/spsays-bff/pkg/validator"
)
// ---------------------------------------------------------------------------
// PostHandler
// ---------------------------------------------------------------------------
type PostHandler struct {
svc *service.PostService
graph *service.SocialGraphService // post reporting
}
func NewPostHandler(svc *service.PostService, graph *service.SocialGraphService) *PostHandler {
return &PostHandler{svc: svc, graph: graph}
}
type reportPostReq struct {
Category string `json:"category"`
Reason string `json:"reason"`
}
// Report files a report against this post. Body: {category, reason?}.
// Auth-required; the reporter is the signed-in viewer.
func (h *PostHandler) Report(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
var req reportPostReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.graph.ReportPost(r.Context(), uid, id, req.Category, req.Reason); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
type createPostReq struct {
Caption string `json:"caption"`
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
ImageURLs []string `json:"image_urls"`
MentionUserIDs []string `json:"mention_user_ids"`
}
func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req createPostReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
v := validator.New()
validator.Required(v, "caption", req.Caption)
validator.Required(v, "source_url", req.SourceURL)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
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
}
response.JSON(w, http.StatusCreated, p)
}
// MentionSuggest — GET /posts/mention-suggest?q=&limit=&offset=. Prioritised
// user suggestions for the composer (following → followers → others).
func (h *PostHandler) MentionSuggest(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
users, err := h.svc.SuggestMentions(r.Context(), uid, q, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
if users == nil {
users = []model.UserSummary{}
}
response.JSON(w, http.StatusOK, users)
}
// ListMentions — GET /posts/{id}/mentions. The users tagged on a post. Public.
func (h *PostHandler) ListMentions(w http.ResponseWriter, r *http.Request) {
users, err := h.svc.Mentions(r.Context(), chi.URLParam(r, "id"))
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, users)
}
// AddMention — POST /posts/{id}/mentions { user_id }. Author only.
func (h *PostHandler) AddMention(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req struct {
UserID string `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.UserID) == "" {
response.Error(w, http.StatusBadRequest, "missing user_id")
return
}
if err := h.svc.AddMention(r.Context(), uid, chi.URLParam(r, "id"), req.UserID); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// RemoveMention — DELETE /posts/{id}/mentions/{userID}. Author only.
func (h *PostHandler) RemoveMention(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.RemoveMention(r.Context(), uid, chi.URLParam(r, "id"), chi.URLParam(r, "userID")); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// Reshare materialises a reshare of the post identified by {id} as a
// new posts row owned by the caller. Idempotent — second call returns
// the same row (still 201). The body is the new (or existing) post so
// the frontend can drop it into its local feed without a refetch.
func (h *PostHandler) Reshare(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
p, err := h.svc.Reshare(r.Context(), id, uid)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusCreated, p)
}
// Unreshare removes a previously recorded reshare. Idempotent.
func (h *PostHandler) Unreshare(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
if err := h.svc.Unreshare(r.Context(), id, uid); err != nil {
mapSocialErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *PostHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// viewerID is "" for anonymous viewers — the service treats that as
// "no reshare hydration" so the public-cache shape is unchanged.
p, err := h.svc.Get(r.Context(), id, middleware.UserID(r.Context()))
if err != nil {
mapSocialErr(w, err)
return
}
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 {
mapSocialErr(w, err)
return
}
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)
return
}
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"`
}
// ReplaceImages swaps the post's image set. Body: {"image_urls": [...]}.
// An empty array removes all images (used for the "remove background" flow).
// Owner-only; non-owners and missing posts both 404 to avoid leaking IDs.
func (h *PostHandler) ReplaceImages(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
var req replaceImagesReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
// Defensive cap — posts in this app only display one background, but
// the schema supports many. Cap at 5 to match the compose-post limit.
if len(req.ImageURLs) > 5 {
response.Error(w, http.StatusBadRequest, "at most 5 images")
return
}
p, err := h.svc.ReplaceImages(r.Context(), id, uid, req.ImageURLs)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, p)
}
type imagePresignReq struct {
ContentType string `json:"content_type"`
}
func (h *PostHandler) ImagePresign(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req imagePresignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, public, err := h.svc.ImagePresign(r.Context(), uid, strings.TrimSpace(req.ContentType))
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]string{
"upload_url": upload,
"public_url": public,
})
}
// ---------------------------------------------------------------------------
// BookmarkHandler
// ---------------------------------------------------------------------------
type BookmarkHandler struct{ svc *service.BookmarkService }
func NewBookmarkHandler(svc *service.BookmarkService) *BookmarkHandler {
return &BookmarkHandler{svc: svc}
}
type createBookmarkReq struct {
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
Excerpt *string `json:"excerpt"`
Note *string `json:"note"`
}
func (h *BookmarkHandler) Create(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req createBookmarkReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if strings.TrimSpace(req.SourceURL) == "" {
response.Error(w, http.StatusBadRequest, "source_url is required")
return
}
b, err := h.svc.Create(r.Context(), uid, req.SourceURL, req.SourceTitle, req.Excerpt, req.Note)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusCreated, b)
}
func (h *BookmarkHandler) List(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
out, err := h.svc.List(r.Context(), uid)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, out)
}
func (h *BookmarkHandler) 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 {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// ---------------------------------------------------------------------------
// CollectionHandler
// ---------------------------------------------------------------------------
type CollectionHandler struct {
svc *service.CollectionService
postRepo *repository.PostRepo
auth *service.AuthService // unused for now; placeholder for follow/mute later
issuer *auth.Issuer // used to detect viewer id on optionally-authed routes
users *repository.UserRepo
}
func NewCollectionHandler(svc *service.CollectionService, posts *repository.PostRepo, issuer *auth.Issuer, users *repository.UserRepo) *CollectionHandler {
return &CollectionHandler{svc: svc, postRepo: posts, issuer: issuer, users: users}
}
type createCollectionReq struct {
Title string `json:"title"`
Description *string `json:"description"`
CoverURL *string `json:"cover_url"`
IsPublic bool `json:"is_public"`
}
func (h *CollectionHandler) Create(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req createCollectionReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
c, err := h.svc.Create(r.Context(), uid, req.Title, req.Description, req.CoverURL, req.IsPublic)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusCreated, c)
}
type updateCollectionReq struct {
Title *string `json:"title"`
Description *string `json:"description"`
CoverURL *string `json:"cover_url"`
IsPublic *bool `json:"is_public"`
}
func (h *CollectionHandler) Update(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
var req updateCollectionReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
c, err := h.svc.Update(r.Context(), id, uid, req.Title, req.Description, req.CoverURL, req.IsPublic)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, c)
}
func (h *CollectionHandler) 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 {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
func (h *CollectionHandler) ListMine(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
out, err := h.svc.ListMine(r.Context(), uid)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, out)
}
// Get is public-or-private depending on the collection's `is_public` flag.
// Anonymous viewers can read public collections; private ones require the
// owner to be logged in. We try to read the JWT cookie best-effort to
// detect the viewer's identity.
func (h *CollectionHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
viewerID := optionalUserID(r, h.issuer, h.users)
c, posts, err := h.svc.Get(r.Context(), id, viewerID)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]any{
"collection": c,
"posts": posts,
})
}
type collectionItemReq struct {
PostID string `json:"post_id"`
}
func (h *CollectionHandler) AddItem(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
collectionID := chi.URLParam(r, "id")
var req collectionItemReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.AddItem(r.Context(), collectionID, req.PostID, uid); err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (h *CollectionHandler) RemoveItem(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
collectionID := chi.URLParam(r, "id")
postID := chi.URLParam(r, "post_id")
if err := h.svc.RemoveItem(r.Context(), collectionID, postID, uid); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
type reorderItemsReq struct {
PostIDs []string `json:"post_ids"`
}
// ReorderItems takes an ordered list of post ids and rewrites their
// positions in the collection. Owner-only. Body: {"post_ids": [...]}.
func (h *CollectionHandler) ReorderItems(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
collectionID := chi.URLParam(r, "id")
var req reorderItemsReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetItemOrder(r.Context(), collectionID, uid, req.PostIDs); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// ContainingPost answers "which of my collections already contain post
// X?" — used by the save-to-collection drawer so a post can't be added
// twice. Response: {"collection_ids": ["uuid", ...]}.
func (h *CollectionHandler) ContainingPost(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
postID := chi.URLParam(r, "post_id")
ids, err := h.svc.CollectionsContaining(r.Context(), postID, uid)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string][]string{
"collection_ids": ids,
})
}
func (h *CollectionHandler) CoverPresign(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req imagePresignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
upload, public, err := h.svc.CoverPresign(r.Context(), uid, strings.TrimSpace(req.ContentType))
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, map[string]string{
"upload_url": upload,
"public_url": public,
})
}
// ---------------------------------------------------------------------------
// ProfileHandler — public /api/v1/u/{handle}
// ---------------------------------------------------------------------------
type ProfileHandler struct {
users *repository.UserRepo
posts *repository.PostRepo
collections *repository.CollectionRepo
graph *service.SocialGraphService
likes *repository.LikeRepo
mentions *repository.MentionRepo
comments *repository.CommentRepo
}
func NewProfileHandler(users *repository.UserRepo, posts *repository.PostRepo, collections *repository.CollectionRepo, 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) {
handle := chi.URLParam(r, "handle")
u, err := h.users.FindByHandle(r.Context(), handle)
if err != nil {
mapSocialErr(w, err)
return
}
count, err := h.posts.CountByUser(r.Context(), u.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load profile")
return
}
// Hidden-post count is meaningful only to the owner (drives the /u/me
// "Hidden" tab). Compute it just for self; 0 for everyone else.
hiddenCount := 0
if middleware.UserID(r.Context()) == u.ID {
hiddenCount, err = h.posts.CountHiddenByUser(r.Context(), u.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load profile")
return
}
}
cols, err := h.collections.ListPublicByUser(r.Context(), u.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load collections")
return
}
// Follower/following counts + the viewer's relationship to this
// profile. viewerID is "" for anonymous viewers (OptionalAuth) and
// equal to u.ID when the owner hits their own /u/{handle} — both
// cases yield a nil `relationship` so the frontend hides the
// follow/block affordances.
viewerID := middleware.UserID(r.Context())
followers, following, rel, err := h.graph.ProfileExtras(r.Context(), viewerID, u)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load relationship")
return
}
response.JSON(w, http.StatusOK, map[string]any{
"handle": u.Handle,
"name": u.Name,
"avatar_url": u.AvatarURL,
"bio": u.Bio,
"joined_at": u.CreatedAt,
"country": u.Country,
"post_count": count,
"hidden_count": hiddenCount,
"follower_count": followers,
"following_count": following,
"relationship": rel,
"public_collections": cols,
// True when an admin has linked this account to a record in the
// disciples directory — i.e. one of Śrīla Prabhupāda's own initiated
// disciples. Drives the badge on the profile header. Only the boolean
// is exposed; the underlying Mongo id stays internal.
"is_direct_disciple": u.DiscipleID != nil && *u.DiscipleID != "",
})
}
// gateByBlock resolves the profile owner by handle and returns ErrBlocked
// (→403) when a block exists in either direction between the signed-in
// viewer and the owner — so a blocked viewer can't read the owner's feed,
// tagged posts, or follower lists, even by hitting the API directly. It
// returns the resolved owner so callers needn't look it up again, and
// propagates ErrUserNotFound (→404) for an unknown handle. Anonymous
// viewers and owners viewing their own profile are never gated.
func (h *ProfileHandler) gateByBlock(r *http.Request, handle string) (*model.User, error) {
owner, err := h.users.FindByHandle(r.Context(), handle)
if err != nil {
return nil, err
}
viewerID := middleware.UserID(r.Context())
if viewerID == "" || viewerID == owner.ID {
return owner, nil
}
blocked, err := h.graph.IsBlockedBetween(r.Context(), viewerID, owner.ID)
if err != nil {
return nil, err
}
if blocked {
return nil, service.ErrBlocked
}
return owner, nil
}
// Feed returns posts for the public feed. Same payload as ListMine but
// looked up by handle so it's reachable anonymously. When an
// authenticated viewer hits this route (via OptionalAuth) each post
// carries `viewer_has_reshared` so the action footer renders the right
// button. Anonymous viewers get `false` for every post at zero cost.
func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
owner, err := h.gateByBlock(r, handle)
if err != nil {
mapSocialErr(w, err)
return
}
limit, offset := parseLimitOffset(r)
// gateByBlock already resolved the owner, so list by id rather than
// re-resolving the handle. Hidden posts never appear here (they live in
// the owner's separate Hidden tab).
//
// Keyset path (scroll feed): a `before` cursor pages older posts. Without a
// cursor we keep the offset+meta path the profile grid relies on.
if beforeAt, beforeID, ok := parseCursor(r); ok {
posts, err := h.posts.ListByUserKeyset(r.Context(), owner.ID, beforeAt, beforeID, limit)
if err != nil {
mapSocialErr(w, err)
return
}
if err := h.hydrateFeed(r, posts); err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, posts)
return
}
posts, total, err := h.posts.ListByUser(r.Context(), owner.ID, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
if err := h.hydrateFeed(r, posts); err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
// MentionsFeed — GET /u/{handle}/mentions?limit=&offset=. Posts that tag this
// user, newest first. Public (OptionalAuth) with viewer-state hydration.
func (h *ProfileHandler) MentionsFeed(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
if _, err := h.gateByBlock(r, handle); err != nil {
mapSocialErr(w, err)
return
}
limit, offset := parseLimitOffset(r)
posts, total, err := h.posts.ListMentioningByHandle(r.Context(), handle, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
if err := h.hydrateFeed(r, posts); err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
// hydrateFeed fills viewer reshare state + like counts/state + mention counts
// across a page of posts. Shared by the profile feed and the mentions feed.
func (h *ProfileHandler) hydrateFeed(r *http.Request, posts []model.Post) error {
if len(posts) == 0 {
return nil
}
viewerID := middleware.UserID(r.Context())
if viewerID != "" {
ids := make([]string, 0, len(posts))
for i := range posts {
if posts[i].UserID != viewerID { // can't reshare your own post
ids = append(ids, posts[i].ID)
}
}
if len(ids) > 0 {
states, err := h.posts.ReshareStatesFor(r.Context(), viewerID, ids)
if err != nil {
return err
}
for i := range posts {
if states[posts[i].ID] {
posts[i].ViewerHasReshared = true
}
}
}
}
ptrs := make([]*model.Post, len(posts))
for i := range posts {
ptrs[i] = &posts[i]
}
if err := h.likes.Hydrate(r.Context(), viewerID, ptrs); err != nil {
return err
}
if err := h.mentions.HydrateCounts(r.Context(), ptrs); err != nil {
return err
}
return h.comments.Hydrate(r.Context(), viewerID, ptrs)
}
// followListResp is the payload for the followers / following list endpoints:
// the page of users plus the total count (so the client can paginate). Total
// rides in `data` rather than meta because the frontend's fetch wrapper only
// surfaces `data`.
type followListResp struct {
Users []model.UserSummary `json:"users"`
Total int `json:"total"`
}
// Followers — GET /u/{handle}/followers?limit=&offset=&q=. Paginated, optional
// name/handle search. Public (OptionalAuth).
func (h *ProfileHandler) Followers(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
if _, err := h.gateByBlock(r, handle); err != nil {
mapSocialErr(w, err)
return
}
limit, offset := parseLimitOffset(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
users, total, err := h.graph.Followers(r.Context(), handle, q, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
}
// Following — GET /u/{handle}/following?limit=&offset=&q=.
func (h *ProfileHandler) Following(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
if _, err := h.gateByBlock(r, handle); err != nil {
mapSocialErr(w, err)
return
}
limit, offset := parseLimitOffset(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
users, total, err := h.graph.Following(r.Context(), handle, q, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
}
// IncomingFollowRequests — GET /follow-requests?limit=&offset=&q=. The caller's
// pending incoming follow requests (auth required), for the notifications
// "Friend Requests" tab where they're accepted/rejected.
func (h *ProfileHandler) IncomingFollowRequests(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
users, total, err := h.graph.IncomingRequests(r.Context(), uid, q, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, followListResp{Users: users, Total: total})
}
// ---------------------------------------------------------------------------
// shared helpers
// ---------------------------------------------------------------------------
func mapSocialErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrUserNotFound),
errors.Is(err, repository.ErrPostNotFound),
errors.Is(err, repository.ErrCommentNotFound),
errors.Is(err, repository.ErrBookmarkNotFound),
errors.Is(err, repository.ErrCollectionNotFound),
errors.Is(err, service.ErrNotFound):
response.Error(w, http.StatusNotFound, "not found")
case errors.Is(err, repository.ErrDuplicateComment):
response.Error(w, http.StatusConflict, "you've already posted this comment")
case errors.Is(err, service.ErrForbidden):
response.Error(w, http.StatusForbidden, "not allowed")
case errors.Is(err, service.ErrBlocked):
response.Error(w, http.StatusForbidden, "this action isn't available between you and this user")
case errors.Is(err, service.ErrPlanLimit):
response.Error(w, http.StatusPaymentRequired, err.Error())
default:
response.Error(w, http.StatusBadRequest, err.Error())
}
}
func parseLimitOffset(r *http.Request) (limit, offset int) {
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
offset = n
}
}
if limit <= 0 || limit > 50 {
limit = 20
}
if offset < 0 {
offset = 0
}
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
}
totalPages := int(math.Ceil(float64(total) / float64(limit)))
return &response.Meta{
Page: (offset / limit) + 1,
Limit: limit,
Total: int64(total),
TotalPages: totalPages,
}
}
// optionalUserID reads the auth cookie if present and verifies the JWT —
// used by routes that show different data to logged-in vs anonymous viewers
// (e.g. private collections). Returns "" on any failure (treated as anon).
func optionalUserID(r *http.Request, issuer *auth.Issuer, users *repository.UserRepo) string {
raw := auth.Read(r)
if raw == "" || issuer == nil {
return ""
}
claims, err := issuer.Parse(raw)
if err != nil {
return ""
}
if users != nil {
u, err := users.FindByID(r.Context(), claims.UserID)
if err != nil || u.TokenVersion != claims.TokenVersion {
return ""
}
}
return claims.UserID
}
+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)
}
+158
View File
@@ -0,0 +1,158 @@
package handler
import (
"html/template"
"net/http"
"time"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
// StatsHandler renders a single-page HTML snapshot of the system —
// aggregate counts plus the most-recent feedback rows. Mounted at /stats
// (outside /api/v1, since it returns HTML not JSON) and intentionally
// public per current product call. If that ever needs to change, wrap
// the route in middleware in server.go.
//
// It is READ-ONLY: feedback acknowledge/resolve actions live in the
// authenticated admin panel, never on this public page.
//
// PRIVACY: because the page is public, it must never render personal contact
// details. Anonymous submitters' email addresses are shown as "reply address
// on file" only — and FeedbackRepo.Recent doesn't even SELECT the column, so
// the address isn't in memory here to leak by accident. Anything added to this
// template needs the same scrutiny; the admin panel is the place for PII.
type StatsHandler struct {
stats *repository.StatsRepo
feedback *repository.FeedbackRepo
}
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo) *StatsHandler {
return &StatsHandler{stats: stats, feedback: feedback}
}
func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
snap, err := h.stats.Snapshot(r.Context())
if err != nil {
http.Error(w, "stats query failed: "+err.Error(), http.StatusInternalServerError)
return
}
recent, err := h.feedback.Recent(r.Context(), 25)
if err != nil {
http.Error(w, "recent feedback query failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Caching off — this is an ops snapshot; staleness defeats the point.
w.Header().Set("Cache-Control", "no-store")
if err := statsTpl.Execute(w, map[string]any{
"Stats": snap,
"Feedback": recent,
"GeneratedAt": time.Now().Format("2006-01-02 15:04:05 MST"),
}); err != nil {
// Headers already flushed — best we can do is bail mid-stream.
return
}
}
var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{
"fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
}).Parse(statsHTML))
const statsHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Stats · Prabhupāda Says</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; max-width: 920px; margin: 2.5rem auto; padding: 0 1.5rem; color: #1a1a1a; line-height: 1.5 }
h1 { font-size: 1.5rem; margin-bottom: 0.25rem }
h2 { font-size: 1.05rem; margin-top: 2rem; margin-bottom: 0.5rem; border-bottom: 1px solid #e5e5e5; padding-bottom: 0.25rem }
.meta { color: #888; font-size: 0.8rem; margin-bottom: 1.5rem }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.75rem }
.card { background: #fafafa; border: 1px solid #ececec; border-radius: 8px; padding: 0.9rem 1rem }
.card .num { font-size: 1.4rem; font-weight: 600; line-height: 1.1 }
.card .num .delta { font-size: 0.75rem; font-weight: 500; color: #1e7a4a; margin-left: 0.4rem }
.card .label { color: #666; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.35rem }
.row { display: flex; justify-content: space-between; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #f0f0f0; font-size: 0.92rem }
.row:last-child { border-bottom: 0 }
.row .cat { font-weight: 600; text-transform: capitalize }
.fb { background: #fafafa; border: 1px solid #ececec; border-radius: 8px; padding: 0.9rem 1rem; margin-bottom: 0.6rem }
.fb.resolved { background: #f3f8f4; border-color: #d6e9da }
.fb .head { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.82rem; color: #666; margin-bottom: 0.4rem; flex-wrap: wrap; align-items: center }
.fb .cat-pill { font-weight: 700; text-transform: capitalize; color: #1a1a1a }
.fb .msg { white-space: pre-wrap; font-size: 0.95rem }
.fb .author { font-size: 0.78rem; color: #888; margin-top: 0.5rem }
.fb .foot { display: flex; justify-content: flex-end; align-items: center; gap: 0.6rem; margin-top: 0.6rem }
.resolved-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #1e7a4a }
.acked-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #9a6b2f }
.pending-badge { display: inline-flex; align-items: center; font-size: 0.78rem; font-weight: 600; color: #999 }
.resolve-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #fff; background: #1e7a4a; border: 0; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
.resolve-btn:hover { background: #196b40 }
.ack-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #9a6b2f; background: #fff; border: 1px solid #d6b27a; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
.ack-btn:hover { background: #faf5ea }
code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size: 0.85em }
.empty { color: #888; font-style: italic }
</style>
</head>
<body>
<h1>System snapshot</h1>
<p class="meta">Generated {{.GeneratedAt}}</p>
<h2>Users &amp; activity</h2>
<div class="grid">
<div class="card"><div class="label">Users</div><div class="num">{{.Stats.Users}}<span class="delta">+{{.Stats.UsersLast7Days}} / 7d</span></div></div>
<div class="card"><div class="label">Posts</div><div class="num">{{.Stats.Posts}}<span class="delta">+{{.Stats.PostsLast7Days}} / 7d</span></div></div>
<div class="card"><div class="label">Collections</div><div class="num">{{.Stats.Collections}}<span class="delta">{{.Stats.PublicCollections}} public</span></div></div>
<div class="card"><div class="label">Bookmarks</div><div class="num">{{.Stats.Bookmarks}}</div></div>
<div class="card"><div class="label">Reshares</div><div class="num">{{.Stats.Reshares}}</div></div>
<div class="card"><div class="label">Feedback</div><div class="num">{{.Stats.Feedback}}<span class="delta">+{{.Stats.FeedbackLast7Days}} / 7d</span></div></div>
</div>
<h2>Feedback by category</h2>
{{if .Stats.FeedbackByCategory}}
{{range .Stats.FeedbackByCategory}}
<div class="row"><span class="cat">{{.Category}}</span><span>{{.Count}}</span></div>
{{end}}
{{else}}
<p class="empty">No feedback yet.</p>
{{end}}
<h2>Recent feedback</h2>
{{if .Feedback}}
{{range .Feedback}}
<div class="fb{{if .ResolvedAt}} resolved{{end}}">
<div class="head">
<span class="cat-pill">{{.Category}}</span>
<span>{{fmtTime .CreatedAt}}</span>
</div>
<div class="msg">{{.Message}}</div>
<div class="author">
{{if .AuthorHandle}}
@{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}}
{{else if .HasContactEmail}}
anonymous · reply address on file
{{else}}
anonymous
{{end}}
</div>
<div class="foot">
{{if .ResolvedAt}}
{{if .AcknowledgedAt}}<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>{{end}}
<span class="resolved-badge">✓ Resolved {{fmtTime .ResolvedAt}}</span>
{{else if .AcknowledgedAt}}
<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>
{{else}}
<span class="pending-badge">Pending</span>
{{end}}
</div>
</div>
{{end}}
{{else}}
<p class="empty">No feedback yet.</p>
{{end}}
</body>
</html>`
+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 {
+231
View File
@@ -0,0 +1,231 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"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"
)
// UserHandler exposes profile-edit + avatar-presign endpoints.
// All routes here sit behind middleware.RequireAuth.
type UserHandler struct {
svc *service.UserService
}
func NewUserHandler(svc *service.UserService) *UserHandler {
return &UserHandler{svc: svc}
}
// updateProfileReq accepts partial updates. Use pointers so the JSON decoder
// can tell "client explicitly sent null" from "field not present" — the
// service treats nil-name as "leave alone" and ""-name as an error.
type updateProfileReq struct {
Name *string `json:"name"`
AvatarURL *string `json:"avatar_url"`
Handle *string `json:"handle"`
Bio *string `json:"bio"`
Country *string `json:"country"`
// Devotional profile (user-editable).
Sampradaya *string `json:"sampradaya"`
Gender *string `json:"gender"`
Initiated *bool `json:"initiated"`
InitiatedAt *string `json:"initiated_at"`
DailyChantingRounds *int `json:"daily_chanting_rounds"`
// System-managed notification fields — accepted here so future logic
// can set them via the same endpoint; no UI sends them today.
PreferredChantingTime *string `json:"preferred_chanting_time"`
PeakActiveTime *string `json:"peak_active_time"`
}
// UpdateProfile patches any subset of the editable profile fields.
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req updateProfileReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
u, err := h.svc.UpdateProfile(r.Context(), uid, service.UpdateProfileInput{
Name: req.Name,
AvatarURL: req.AvatarURL,
Handle: req.Handle,
Bio: req.Bio,
Country: req.Country,
Sampradaya: req.Sampradaya,
Gender: req.Gender,
Initiated: req.Initiated,
InitiatedAt: req.InitiatedAt,
DailyChantingRounds: req.DailyChantingRounds,
PreferredChantingTime: req.PreferredChantingTime,
PeakActiveTime: req.PeakActiveTime,
})
if err != nil {
if errors.Is(err, repository.ErrHandleTaken) {
response.Error(w, http.StatusConflict, "that handle is already in use")
return
}
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusOK, u)
}
// userSearchItem is the public-safe slice of model.User returned by the
// search endpoint. Sensitive fields (email, password hash, role, plan,
// token_version, timestamps) are intentionally omitted.
type userSearchItem struct {
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url,omitempty"`
}
// Search handles GET /api/v1/users/search?q=...&limit=...
//
// Public endpoint — anonymous and signed-in viewers alike can search
// for users by handle or display name. Returns a sanitized payload
// without auth-sensitive fields. Used by the global /search page when
// the query carries an "@" prefix.
func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
limit := 20
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
limit = n
}
}
if q == "" {
response.JSON(w, http.StatusOK, map[string]any{
"query": "",
"results": []userSearchItem{},
"total": 0,
"page": 1,
"limit": limit,
"total_pages": 0,
})
return
}
page := 1
if v := r.URL.Query().Get("page"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 1 {
page = n
}
}
users, total, err := h.svc.Search(r.Context(), q, limit, (page-1)*limit)
if err != nil {
response.Error(w, http.StatusInternalServerError, "search failed")
return
}
items := make([]userSearchItem, 0, len(users))
for _, u := range users {
items = append(items, userSearchItem{
Handle: u.Handle,
Name: u.Name,
AvatarURL: u.AvatarURL,
})
}
totalPages := (total + limit - 1) / limit
response.JSON(w, http.StatusOK, map[string]any{
"query": q,
"results": items,
"total": total,
"page": page,
"limit": limit,
"total_pages": totalPages,
})
}
// GetReadingPrefs returns the caller's saved reading prefs blob (always
// well-formed JSON; an empty object when the user has never written).
// The frontend treats the response as the source of truth on first
// sign-in for this device.
func (h *UserHandler) GetReadingPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
raw, err := h.svc.GetReadingPrefs(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load reading prefs")
return
}
// Round-trip the JSONB as the data field directly. We use a raw
// json.RawMessage so the body is `{"data": {...}}` not a re-encoded
// string. The envelope wrapper keeps shape parity with the rest of
// the API.
response.JSON(w, http.StatusOK, json.RawMessage(raw))
}
// UpdateReadingPrefs replaces the caller's reading-prefs blob with the
// request body (which must itself be a JSON object — we re-marshal a
// decoded map to enforce that without imposing a fixed schema on a
// payload the frontend owns).
func (h *UserHandler) UpdateReadingPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
// Decode into map[string]any to guarantee the body is an object (not
// an array, number, or string). Re-marshal before persisting so the
// stored JSON is canonicalised and any duplicate keys are dropped.
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body (expect an object)")
return
}
prefsJSON, err := json.Marshal(body)
if err != nil {
response.Error(w, http.StatusBadRequest, "could not re-encode prefs")
return
}
if err := h.svc.UpdateReadingPrefs(r.Context(), uid, prefsJSON); err != nil {
response.Error(w, http.StatusInternalServerError, "could not save reading prefs")
return
}
response.JSON(w, http.StatusOK, json.RawMessage(prefsJSON))
}
type presignReq struct {
ContentType string `json:"content_type"`
}
type presignResp struct {
UploadURL string `json:"upload_url"`
PublicURL string `json:"public_url"`
}
// AvatarPresign returns a one-shot R2 PUT URL the browser uses to upload.
// The frontend then PATCHes /users/me with the returned public_url to
// persist the new avatar on the user row.
func (h *UserHandler) AvatarPresign(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req presignReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
ct := strings.TrimSpace(req.ContentType)
if ct == "" {
response.Error(w, http.StatusBadRequest, "content_type is required")
return
}
upload, public, err := h.svc.AvatarPresign(r.Context(), uid, ct)
if err != nil {
// Differentiate "feature off" from "bad content type" so the frontend
// can show a useful message.
if strings.Contains(err.Error(), "not configured") {
response.Error(w, http.StatusServiceUnavailable, err.Error())
return
}
response.Error(w, http.StatusBadRequest, err.Error())
return
}
response.JSON(w, http.StatusOK, presignResp{UploadURL: upload, PublicURL: public})
}
+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) }
+94 -26
View File
@@ -2,56 +2,124 @@ package middleware
import (
"context"
"errors"
"net/http"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
type contextKey string
type ctxKey string
const UserIDKey contextKey = "user_id"
const (
userIDKey ctxKey = "user_id"
tokenVersionKey ctxKey = "tv"
)
// Auth is a placeholder middleware for JWT authentication.
// It extracts the Bearer token from the Authorization header,
// validates it, and sets the user ID in the request context.
func Auth(jwtSecret string) func(next http.Handler) http.Handler {
// RequireAuth is the middleware applied to every protected route.
//
// It pulls the JWT from the cookie, verifies signature + expiry, then asks
// the UserRepo for the current token_version and rejects if the JWT carries
// a stale version (password-change or forced logout invalidates older JWTs).
//
// On success it stuffs (user_id, token_version) into the request context;
// handlers read them via UserID() / TokenVersion().
func RequireAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
response.Error(w, http.StatusUnauthorized, "missing authorization header")
raw := auth.Read(r)
if raw == "" {
response.Error(w, http.StatusUnauthorized, "not authenticated")
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
response.Error(w, http.StatusUnauthorized, "invalid authorization format")
claims, err := issuer.Parse(raw)
if err != nil {
response.Error(w, http.StatusUnauthorized, "invalid or expired session")
return
}
token := parts[1]
// TODO: validate JWT token and extract user ID
_ = token
_ = jwtSecret
userID := "" // placeholder
if userID == "" {
response.Error(w, http.StatusUnauthorized, "invalid or expired token")
u, err := users.FindByID(r.Context(), claims.UserID)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
response.Error(w, http.StatusUnauthorized, "account no longer exists")
return
}
response.Error(w, http.StatusInternalServerError, "auth check failed")
return
}
if u.TokenVersion != claims.TokenVersion {
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, userID)
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// GetUserID extracts the user ID from the request context.
func GetUserID(ctx context.Context) string {
if v, ok := ctx.Value(UserIDKey).(string); ok {
// OptionalAuth is the soft-auth sibling of RequireAuth. Same logic, but
// invalid / missing sessions don't reject the request — they just skip
// the context population. Use it on routes that are reachable
// anonymously but want to personalise the response when the viewer
// happens to be signed in (e.g. `viewer_has_reshared` on a public
// post).
func OptionalAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := auth.Read(r)
if raw == "" {
next.ServeHTTP(w, r)
return
}
claims, err := issuer.Parse(raw)
if err != nil {
next.ServeHTTP(w, r)
return
}
u, err := users.FindByID(r.Context(), claims.UserID)
if err != nil || u.TokenVersion != claims.TokenVersion {
// Stale or invalid session — proceed anonymously rather
// than failing the request. RequireAuth would 401 here.
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// WithUserID returns a context carrying userID, as RequireAuth would set it.
// The exported counterpart to UserID: it lets handlers be exercised in tests
// without standing up a JWT issuer and a user repository.
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userIDKey, userID)
}
// UserID returns the authenticated user's id from the request context.
// Returns "" if RequireAuth has not run.
func UserID(ctx context.Context) string {
if v, ok := ctx.Value(userIDKey).(string); ok {
return v
}
return ""
}
// TokenVersion returns the JWT's token_version claim from context.
func TokenVersion(ctx context.Context) int {
if v, ok := ctx.Value(tokenVersionKey).(int); ok {
return v
}
return 0
}
+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"`
}
+67
View File
@@ -0,0 +1,67 @@
package model
import "time"
// Feedback is a user-submitted message — bug report, feature request,
// or general comment — captured by the /feedback page. Surfaced to
// admins both via the /stats page and via an email fan-out on insert.
//
// UserID is a pointer so the model can carry anonymous submissions
// (logged-out visitors) and orphaned rows (the submitter has since
// deleted their account — the FK is set null). ContactEmail is the
// optional reply-to filled in by anonymous submitters.
//
// AuthorHandle / AuthorName / AuthorEmail are populated by joins in
// the repo's lookup helpers (used by the admin email template and the
// /stats page). They're nil for anonymous rows.
type Feedback struct {
ID string `json:"id"`
UserID *string `json:"user_id,omitempty"`
Category string `json:"category"`
// 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
// /stats page — the team has seen it and (if it needs work) the devs
// have started. NULL until then. One-way like ResolvedAt.
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
// ResolvedAt is set the moment an admin presses "Mark resolved" on
// the /stats page. NULL while open. The action is one-way — the
// repo's MarkResolved guards on `resolved_at IS NULL` so a second
// resolve is a no-op.
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
// ReplyMessage is the admin's written reply to the submitter, sent
// (and stored) from the admin panel. NULL until an admin replies.
// RepliedAt stamps when it was sent. Surfaced to the submitter on the
// "My feedback" page as the team's response.
ReplyMessage *string `json:"reply_message,omitempty"`
RepliedAt *time.Time `json:"replied_at,omitempty"`
// HasContactEmail reports *whether* an anonymous submitter left a reply-to
// address, without carrying the address itself. Populated only by
// Recent(), whose sole consumer is the PUBLIC /stats page — that page has
// to distinguish "anonymous, but we can reply" from "anonymous dead end"
// while never handling the email. Admins see the real address in the
// admin panel, which is auth-gated.
HasContactEmail bool `json:"-"`
AuthorHandle *string `json:"author_handle,omitempty"`
AuthorName *string `json:"author_name,omitempty"`
AuthorEmail *string `json:"-"` // server-side only; never serialised
}
// 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
}
+279
View File
@@ -0,0 +1,279 @@
package model
import "time"
// Post is a public quote on a user's feed. Caption is the verbatim selected
// text from the knowledgebase; SourceURL links back to the page the user
// was reading. Images are loaded separately via PostImage rows.
//
// Reshares are real Post rows owned by the resharer, with
// ResharedFromPostID pointing to the canonical original. Likes, comments,
// and images all hang off the post's own id, so they diverge naturally
// across reshare boundaries — no parallel "per-resharer" tables.
type Post struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Caption string `json:"caption"`
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
Images []PostImage `json:"images"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// IsHidden — owner has hidden this post from timelines (their profile as
// seen by others, the home feed) and from public collections. It still
// shows to the owner on their own profile and in private collections.
IsHidden bool `json:"is_hidden"`
// Author info — populated by repo joins for feed listings.
AuthorName string `json:"author_name,omitempty"`
AuthorHandle string `json:"author_handle,omitempty"`
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
// Reshare attribution. ResharedFromPostID is non-nil when this post
// is a reshare; the four `Original*` fields carry the canonical
// post's author info so the frontend can render a
// "Reshared from @handle" link without an extra round-trip.
ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"`
OriginalAuthorName *string `json:"original_author_name,omitempty"`
OriginalAuthorHandle *string `json:"original_author_handle,omitempty"`
OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"`
// ViewerHasReshared is true when the calling user has reshared the
// canonical version of this post. Hydrated by the service layer
// after the repo returns posts; always false for anonymous viewers.
// Drives the post-card "Reshare ↔ Send" swap on the frontend.
ViewerHasReshared bool `json:"viewer_has_reshared"`
// Likes. LikesCount is the effective total = the denormalized
// posts.likes_count plus any still-pending Redis delta (hydrated by the
// like layer). ViewerHasLiked reflects the calling user's membership row;
// false for anonymous viewers.
LikesCount int `json:"likes_count"`
ViewerHasLiked bool `json:"viewer_has_liked"`
// MentionsCount is how many users are tagged on this post (drives the
// post-card mentions icon). Hydrated by the mention layer.
MentionsCount int `json:"mentions_count"`
// PinnedComment is the post's pinned comment (if any), hydrated by the
// comment layer so the card footer can show it without a per-card fetch.
// Nil when nothing is pinned.
PinnedComment *Comment `json:"pinned_comment,omitempty"`
// CommentsCount is the total number of comments + replies on the post
// (drives the footer's comment count). Hydrated by the comment layer.
CommentsCount int `json:"comments_count"`
// Viewer↔author relationship, hydrated for the home feed so the card can
// render a Follow / Follow Back affordance. ViewerFollowsAuthor: the
// caller follows (accepted) this post's author. AuthorFollowsViewer: the
// author follows the caller. Both false for anonymous viewers / own posts.
ViewerFollowsAuthor bool `json:"viewer_follows_author"`
AuthorFollowsViewer bool `json:"author_follows_viewer"`
// ViewerFollowRequested: the caller has a pending (unaccepted) follow
// request to this post's author. Drives the "Requested" feed affordance.
ViewerFollowRequested bool `json:"viewer_follow_requested"`
}
// Comment is one comment (or reply) on a post. Threading is one level deep:
// ParentID is nil for a top-level comment and otherwise points at the thread
// root. ReplyToHandle, when set, is the @handle the reply was addressed to
// (replies stay flat under the root; this just preserves who was answered).
type Comment struct {
ID string `json:"id"`
PostID string `json:"post_id"`
UserID string `json:"user_id"`
ParentID *string `json:"parent_id,omitempty"`
Body string `json:"body"`
LikesCount int `json:"likes_count"`
PinnedAt *time.Time `json:"pinned_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Author info — populated by repo joins.
AuthorName string `json:"author_name"`
AuthorHandle string `json:"author_handle"`
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
// ReplyToHandle is the handle this reply was addressed to (nil on
// top-level comments), for the "Replying to @handle" prefix.
ReplyToHandle *string `json:"reply_to_handle,omitempty"`
// ViewerHasLiked reflects the calling user's like membership; false for
// anonymous viewers. ReplyCount is the number of replies under a
// top-level comment (0 on replies themselves).
ViewerHasLiked bool `json:"viewer_has_liked"`
ReplyCount int `json:"reply_count"`
}
// PostImage represents one attached image on a post.
type PostImage struct {
ID string `json:"id"`
URL string `json:"url"`
Position int `json:"position"`
}
// Bookmark is a private "save my place" marker.
type Bookmark struct {
ID string `json:"id"`
UserID string `json:"user_id"`
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
Excerpt *string `json:"excerpt,omitempty"`
Note *string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CollectionRecord is a user-curated group of posts.
//
// Renamed to avoid colliding with the legacy `Collection` model in user.go
// from when bookmarks/collections were sketched out earlier. Once that old
// model is unused we can drop it; for now this keeps both compiling.
type CollectionRecord struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
CoverURL *string `json:"cover_url,omitempty"`
IsPublic bool `json:"is_public"`
ItemCount int `json:"item_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// 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"`
// Bio is plain text; `@handle` mentions are linkified at render time.
Bio *string `json:"bio"`
JoinedAt time.Time `json:"joined_at"`
PostCount int `json:"post_count"`
BookmarkCount int `json:"-"` // never serialized — private
// IsDirectDisciple marks an account an admin has linked to the disciples
// directory — one of Śrīla Prabhupāda's own initiated disciples.
IsDirectDisciple bool `json:"is_direct_disciple"`
}
// RelationshipState is the viewer's relationship to a target profile,
// computed fresh per request from the follows + blocks tables. Every
// field is "from the viewer looking at the target":
//
// - Following: viewer → target follow edge is accepted.
// - FollowRequested: viewer → target follow edge is pending (awaiting
// the target's approval). The "Requested" button state.
// - FollowsYou: target → viewer follow edge is accepted (they
// already follow the viewer). Drives the "Follow back" label and
// the "Remove from followers" menu item.
// - RequestsYou: target → viewer follow edge is pending (the target
// asked to follow the viewer). Drives the Accept / Reject buttons.
// - Blocked: viewer has blocked the target.
// - BlockedBy: target has blocked the viewer.
//
// nil (not this struct's zero value) is returned for anonymous viewers
// and for a user viewing their own profile — callers special-case those.
type RelationshipState struct {
Following bool `json:"following"`
FollowRequested bool `json:"follow_requested"`
FollowsYou bool `json:"follows_you"`
RequestsYou bool `json:"requests_you"`
Blocked bool `json:"blocked"`
BlockedBy bool `json:"blocked_by"`
// 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
// edge from Follower to Following. Status is "pending" until the target
// accepts.
type Follow struct {
ID string `json:"id"`
FollowerID string `json:"follower_id"`
FollowingID string `json:"following_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
RespondedAt *time.Time `json:"responded_at,omitempty"`
}
// UserBlock is one row of user_blocks — Blocker has blocked Blocked.
type UserBlock struct {
ID string `json:"id"`
BlockerID string `json:"blocker_id"`
BlockedID string `json:"blocked_id"`
CreatedAt time.Time `json:"created_at"`
}
// UserReport is one row of user_reports — Reporter flagged Reported.
type UserReport struct {
ID string `json:"id"`
ReporterID string `json:"reporter_id"`
ReportedID string `json:"reported_id"`
Category string `json:"category"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
}
// PostReport is one row of post_reports — Reporter flagged a specific
// post. Reason is optional ("" when none given).
type PostReport struct {
ID string `json:"id"`
ReporterID string `json:"reporter_id"`
PostID string `json:"post_id"`
Category string `json:"category"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
}
// Notification is one delivered/queued notification for a recipient
// (UserID). ActorID is who triggered it; Kind is a short machine string.
// Hydrated Actor* fields are filled by repo joins for list rendering.
type Notification struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ActorID *string `json:"actor_id,omitempty"`
Kind string `json:"kind"`
CreatedAt time.Time `json:"created_at"`
ReadAt *time.Time `json:"read_at,omitempty"`
// Aggregation: for post_like, PostID is the liked post and ActorCount is
// how many likers have aggregated into this single row (the frontend
// renders "X and N-1 others liked your post"). Other kinds: PostID nil,
// ActorCount 1.
PostID *string `json:"post_id,omitempty"`
ActorCount int `json:"actor_count"`
ActorName *string `json:"actor_name,omitempty"`
ActorHandle *string `json:"actor_handle,omitempty"`
ActorAvatar *string `json:"actor_avatar_url,omitempty"`
// PostAuthorHandle is the handle of the author of PostID (when set), so
// the client can deep-link to the post for kinds where the actor isn't
// the post owner (e.g. comment_reply). Nil when there's no post.
PostAuthorHandle *string `json:"post_author_handle,omitempty"`
// Body carries free text for the 'admin_message' kind (an admin → user
// direct message). Nil for every event-driven kind.
Body *string `json:"body,omitempty"`
}
// PushSubscription is one browser/device Web Push endpoint a user granted.
type PushSubscription struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Endpoint string `json:"endpoint"`
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
}
+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
}
+148 -38
View File
@@ -1,51 +1,161 @@
package model
import "time"
import (
"encoding/json"
"time"
)
// User represents a user in PostgreSQL.
// User represents a row in the `users` table.
//
// PasswordHash is tagged `json:"-"` so it never leaks via JSON marshalling
// even if a caller accidentally passes a User through response.JSON. It's a
// pointer because OAuth-only users have no password — *PasswordHash == nil
// means "this account can only sign in via a linked OAuth provider".
//
// UserSummary is the lightweight projection used in follower / following
// lists — just enough to render a row and link to the profile.
type UserSummary struct {
// ID is populated only where the caller needs it (e.g. mention add/remove);
// omitted from follower/like lists that link by handle.
ID string `json:"id,omitempty"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
}
// EmailVerifiedAt is a pointer so JSON-encoded users carry an explicit `null`
// when the user has not yet verified, instead of an omitted field — the
// frontend uses this to decide whether to show the "verify your email" ribbon.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Avatar *string `json:"avatar,omitempty"`
Role string `json:"role"` // "user" | "admin" | "moderator"
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
// Email is nullable: OAuth users (typically Facebook accounts with no
// verified email on file) sign up without one and can fill it in later
// from their profile.
Email *string `json:"email"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
Role string `json:"role"`
Plan string `json:"plan"` // "free" | "premium"
// Country is the account's ISO-3166-1 alpha-2 code (e.g. "IN"),
// captured best-effort at signup and editable from the profile form.
// nil until known.
Country *string `json:"country"`
// DiscipleID links this account to a row in the Mongo `disciples`
// collection (ObjectID hex) when the account belongs to one of Śrīla
// Prabhupāda's own initiated disciples. Admin-set only — never
// self-serve — and unique across users, so one disciple maps to at most
// one account. nil for everybody else.
//
// Tagged `json:"-"`: the raw cross-store id is an internal detail. The
// public profile exposes only the derived `is_direct_disciple` flag.
DiscipleID *string `json:"-"`
// Bio is a short self-introduction (max 200 characters, enforced in the
// service by rune count) shown on the profile header. Stored as PLAIN
// TEXT — `@handle` mentions are kept verbatim and linkified by the
// frontend at render time, so it must never be injected as HTML.
// nil when unset.
Bio *string `json:"bio"`
// --- Devotional profile (user-editable) ---
// Gender is a free-but-constrained string ("male"|"female"|"other"),
// nil when unspecified.
Gender *string `json:"gender"`
// Sampradaya is the devotee's spiritual lineage (a lowercase code,
// e.g. "gaudiya"). Optional — new accounts default to "gaudiya" but it
// can be cleared, so nil means "not specified".
Sampradaya *string `json:"sampradaya"`
// Initiated marks whether the devotee has received initiation.
Initiated bool `json:"initiated"`
// InitiatedAt is the initiation date (DATE column), nil when unknown or
// not initiated.
InitiatedAt *time.Time `json:"initiated_at"`
// DailyChantingRounds is the devotee's daily japa round vow (1–1008),
// nil when unspecified.
DailyChantingRounds *int `json:"daily_chanting_rounds"`
// --- Notification scheduling (system-managed; no UI yet) ---
// PreferredChantingTime / PeakActiveTime store a local clock time as
// "HH:MM". They're written silently by the backend to time automated
// notifications; the scheduling logic ships later. nil until computed.
PreferredChantingTime *string `json:"preferred_chanting_time"`
PeakActiveTime *string `json:"peak_active_time"`
EmailVerifiedAt *time.Time `json:"email_verified_at"`
PasswordHash *string `json:"-"`
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:"-"`
}
// Bookmark represents a user's bookmarked content.
type Bookmark struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ContentType string `json:"content_type"` // "verse" | "letter" | "essay" | "transcription" | etc.
ContentID string `json:"content_id"`
ContentRef string `json:"content_ref"` // human-readable reference (e.g., "BG 1.1")
SelectedText *string `json:"selected_text,omitempty"`
Note *string `json:"note,omitempty"`
CreatedAt time.Time `json:"created_at"`
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
// "Change password" vs "Set a password" for OAuth-only accounts, without
// the password hash itself ever leaving the server. `is_direct_disciple` is
// likewise derived — the raw cross-store DiscipleID stays server-side while
// the frontend gets the one bit it needs to render the badge.
func (u User) MarshalJSON() ([]byte, error) {
type alias User
return json.Marshal(&struct {
alias
HasPassword bool `json:"has_password"`
IsDirectDisciple bool `json:"is_direct_disciple"`
Limits UserLimits `json:"limits"`
}{
alias: alias(u),
HasPassword: u.PasswordHash != nil,
IsDirectDisciple: u.DiscipleID != nil && *u.DiscipleID != "",
Limits: u.EffectiveLimits(),
})
}
// Collection represents a user-created collection.
type Collection struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
IsPublic bool `json:"is_public"`
ItemCount int `json:"item_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// AuthToken represents a row in the `auth_tokens` table.
// Used for both password reset and email verification flows.
type AuthToken struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Purpose string `json:"purpose"` // "password_reset" | "email_verification"
TokenHash string `json:"-"`
ExpiresAt time.Time `json:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Mention represents a mention of content shared by a user.
type Mention struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ContentType string `json:"content_type"`
ContentID string `json:"content_id"`
ContentRef string `json:"content_ref"`
Text string `json:"text"`
CreatedAt time.Time `json:"created_at"`
const (
PurposePasswordReset = "password_reset"
PurposeEmailVerification = "email_verification"
// PurposeLoginOTP is a 6-digit one-time code emailed to a user whose
// account has been unverified for >30 days. Verification happens
// per-user (FindValidForUser), so the 1M-keyspace can't be brute-forced
// offline by hash collision — only online against rate limits.
PurposeLoginOTP = "login_otp"
)
// OAuthAccount represents a row in the `oauth_accounts` table — one entry
// per (provider, provider_user_id). A single user can have several linked
// providers.
type OAuthAccount struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Provider string `json:"provider"`
ProviderUserID string `json:"provider_user_id"`
Email *string `json:"email"`
Name *string `json:"name"`
AvatarURL *string `json:"avatar_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ShortURL represents a shortened URL.
+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
}
+96
View File
@@ -0,0 +1,96 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var ErrBookmarkNotFound = errors.New("bookmark not found")
var ErrBookmarkExists = errors.New("bookmark already exists for this URL")
type BookmarkRepo struct{ pg *Postgres }
func NewBookmarkRepo(pg *Postgres) *BookmarkRepo { return &BookmarkRepo{pg: pg} }
// Create inserts a new bookmark. ErrBookmarkExists on (user_id, source_url) collision.
func (r *BookmarkRepo) Create(ctx context.Context, userID, sourceURL, sourceTitle string, excerpt, note *string) (*model.Bookmark, error) {
b := &model.Bookmark{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO bookmarks (user_id, source_url, source_title, excerpt, note)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at`,
userID, sourceURL, sourceTitle, excerpt, note,
).Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle, &b.Excerpt, &b.Note, &b.CreatedAt)
if err != nil {
if strings.Contains(err.Error(), "23505") {
return nil, ErrBookmarkExists
}
return nil, fmt.Errorf("create bookmark: %w", err)
}
return b, nil
}
// ListByUser returns all bookmarks for the caller, newest first.
func (r *BookmarkRepo) ListByUser(ctx context.Context, userID string) ([]model.Bookmark, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at
FROM bookmarks WHERE user_id = $1 ORDER BY created_at DESC`, userID)
if err != nil {
return nil, fmt.Errorf("query bookmarks: %w", err)
}
defer rows.Close()
out := []model.Bookmark{}
for rows.Next() {
var b model.Bookmark
if err := rows.Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle,
&b.Excerpt, &b.Note, &b.CreatedAt); err != nil {
return nil, fmt.Errorf("scan bookmark: %w", err)
}
out = append(out, b)
}
return out, rows.Err()
}
// CountByUser is used by the plan-limit enforcer.
func (r *BookmarkRepo) CountByUser(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM bookmarks WHERE user_id = $1`, userID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count bookmarks: %w", err)
}
return n, nil
}
// Delete removes a bookmark belonging to the caller.
func (r *BookmarkRepo) Delete(ctx context.Context, id, userID string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM bookmarks WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("delete bookmark: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrBookmarkNotFound
}
return nil
}
// FindByID returns one bookmark (used for the owner-check path).
func (r *BookmarkRepo) FindByID(ctx context.Context, id string) (*model.Bookmark, error) {
b := &model.Bookmark{}
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, user_id, source_url, COALESCE(source_title, ''), excerpt, note, created_at
FROM bookmarks WHERE id = $1`, id,
).Scan(&b.ID, &b.UserID, &b.SourceURL, &b.SourceTitle, &b.Excerpt, &b.Note, &b.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrBookmarkNotFound
}
return nil, fmt.Errorf("find bookmark: %w", err)
}
return b, 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
}
+264
View File
@@ -0,0 +1,264 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
var ErrCollectionNotFound = errors.New("collection not found")
type CollectionRepo struct{ pg *Postgres }
func NewCollectionRepo(pg *Postgres) *CollectionRepo { return &CollectionRepo{pg: pg} }
// Create inserts a new collection owned by userID.
func (r *CollectionRepo) Create(ctx context.Context, userID, title string, description, coverURL *string, isPublic bool) (*model.CollectionRecord, error) {
c := &model.CollectionRecord{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO collections (user_id, title, description, cover_url, is_public)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at`,
userID, title, description, coverURL, isPublic,
).Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("create collection: %w", err)
}
return c, nil
}
// FindByID returns one collection by id (no owner check — caller decides
// what to do with the visibility flag).
func (r *CollectionRepo) FindByID(ctx context.Context, id string) (*model.CollectionRecord, error) {
c := &model.CollectionRecord{}
err := r.pg.Pool.QueryRow(ctx, `
SELECT id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at
FROM collections WHERE id = $1`, id,
).Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCollectionNotFound
}
return nil, fmt.Errorf("find collection: %w", err)
}
return c, nil
}
// ListByUser returns all collections for the caller (own listing).
func (r *CollectionRepo) ListByUser(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
return r.list(ctx, `WHERE user_id = $1 ORDER BY updated_at DESC`, userID)
}
// ListPublicByUser returns only the public collections for /u/<handle>.
func (r *CollectionRepo) ListPublicByUser(ctx context.Context, userID string) ([]model.CollectionRecord, error) {
return r.list(ctx, `WHERE user_id = $1 AND is_public = TRUE ORDER BY updated_at DESC`, userID)
}
// CountByUserAndVisibility — used by the plan-limit enforcer
// (free: max 5 public, 5 private).
func (r *CollectionRepo) CountByUserAndVisibility(ctx context.Context, userID string, isPublic bool) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM collections WHERE user_id = $1 AND is_public = $2`,
userID, isPublic,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count collections: %w", err)
}
return n, nil
}
// Update modifies title/description/cover/visibility. Nil pointers mean
// "leave unchanged"; isPublic is unconditional (use a pointer if needed).
func (r *CollectionRepo) Update(ctx context.Context, id, userID string, title, description, coverURL *string, isPublic *bool) (*model.CollectionRecord, error) {
const q = `
UPDATE collections
SET title = COALESCE($3, title),
description = COALESCE($4, description),
cover_url = CASE WHEN $5::boolean THEN $6 ELSE cover_url END,
is_public = COALESCE($7, is_public),
updated_at = now()
WHERE id = $1 AND user_id = $2
RETURNING id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at`
updateCover := coverURL != nil
var coverVal *string
if updateCover && *coverURL != "" {
coverVal = coverURL
}
c := &model.CollectionRecord{}
err := r.pg.Pool.QueryRow(ctx, q, id, userID, title, description, updateCover, coverVal, isPublic).
Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCollectionNotFound
}
return nil, fmt.Errorf("update collection: %w", err)
}
return c, nil
}
// Delete removes a collection (cascades to collection_items via FK).
func (r *CollectionRepo) Delete(ctx context.Context, id, userID string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM collections WHERE id = $1 AND user_id = $2`, id, userID)
if err != nil {
return fmt.Errorf("delete collection: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrCollectionNotFound
}
return nil
}
// AddItem inserts a post into a collection. Idempotent — ON CONFLICT.
func (r *CollectionRepo) AddItem(ctx context.Context, collectionID, postID string) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO collection_items (collection_id, post_id, position)
VALUES ($1, $2, COALESCE(
(SELECT MAX(position) + 1 FROM collection_items WHERE collection_id = $1),
0
))
ON CONFLICT (collection_id, post_id) DO NOTHING`,
collectionID, postID,
)
if err != nil {
return fmt.Errorf("add collection item: %w", err)
}
return nil
}
// SetItemOrder rewrites positions for the given posts so the new order
// matches the input array. Runs in a single transaction so concurrent
// reads see either the old order or the new one — never a half-applied
// state. Posts not in the input list are left untouched (they keep
// whatever position they had); callers should pass the full membership
// when they want a clean ordering.
func (r *CollectionRepo) SetItemOrder(ctx context.Context, collectionID string, postIDs []string) error {
if len(postIDs) == 0 {
return nil
}
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
for i, pid := range postIDs {
if _, err := tx.Exec(ctx, `
UPDATE collection_items SET position = $1
WHERE collection_id = $2 AND post_id = $3`,
i, collectionID, pid,
); err != nil {
return fmt.Errorf("set position %d: %w", i, err)
}
}
return tx.Commit(ctx)
}
// RemoveItem deletes a post from a collection.
func (r *CollectionRepo) RemoveItem(ctx context.Context, collectionID, postID string) error {
_, err := r.pg.Pool.Exec(ctx, `
DELETE FROM collection_items WHERE collection_id = $1 AND post_id = $2`,
collectionID, postID,
)
if err != nil {
return fmt.Errorf("remove collection item: %w", err)
}
return nil
}
// CollectionsContaining returns the ids of collections owned by
// `userID` that already include `postID`. The drawer uses this to grey
// out rows the caller can't add the post to a second time.
func (r *CollectionRepo) CollectionsContaining(ctx context.Context, postID, userID string) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT ci.collection_id
FROM collection_items ci
JOIN collections c ON c.id = ci.collection_id
WHERE ci.post_id = $1 AND c.user_id = $2`,
postID, userID,
)
if err != nil {
return nil, fmt.Errorf("collections containing post: %w", err)
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan collection id: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// PostsIn returns the posts inside a collection, ordered by collection
// position then added_at. Images are NOT hydrated — call
// PostRepo.ImagesFor on the returned ids if your caller renders the
// post visually. The CollectionService.Get path already does this.
func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]model.Post, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url, p.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 AND p.deleted_at IS NULL
ORDER BY ci.position, ci.added_at`,
collectionID,
)
if err != nil {
return nil, fmt.Errorf("query collection posts: %w", err)
}
defer rows.Close()
posts := []model.Post{}
ids := []string{}
for rows.Next() {
var p model.Post
if err := rows.Scan(
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
&p.CreatedAt, &p.UpdatedAt,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL, &p.IsHidden,
); err != nil {
return nil, fmt.Errorf("scan post: %w", err)
}
posts = append(posts, p)
ids = append(ids, p.ID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate collection posts: %w", err)
}
return posts, nil
}
// --- internal helpers ---
func (r *CollectionRepo) list(ctx context.Context, where string, args ...any) ([]model.CollectionRecord, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, user_id, title, description, cover_url, is_public, item_count, created_at, updated_at
FROM collections `+where, args...,
)
if err != nil {
return nil, fmt.Errorf("query collections: %w", err)
}
defer rows.Close()
out := []model.CollectionRecord{}
for rows.Next() {
var c model.CollectionRecord
if err := rows.Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.CoverURL,
&c.IsPublic, &c.ItemCount, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan collection: %w", err)
}
out = append(out, c)
}
return out, rows.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
}
+163
View File
@@ -0,0 +1,163 @@
package repository
import (
"context"
"fmt"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// FeedbackRepo wraps Postgres access for the feedback table.
type FeedbackRepo struct{ pg *Postgres }
func NewFeedbackRepo(pg *Postgres) *FeedbackRepo { return &FeedbackRepo{pg: pg} }
// Create inserts a single feedback row. userID is a pointer so the
// authed and anonymous paths share one method; contactEmail is also a
// pointer because authed submissions don't carry one (we read the
// submitter's email from the joined user row at notify time).
func (r *FeedbackRepo) Create(ctx context.Context, userID *string, category, message string, contactEmail *string) (*model.Feedback, error) {
f := &model.Feedback{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO feedback (user_id, category, message, contact_email)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, category, message, contact_email, created_at`,
userID, category, message, contactEmail,
).Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt)
if err != nil {
return nil, fmt.Errorf("insert feedback: %w", err)
}
return f, nil
}
// FindByID returns one feedback row joined with the submitter's
// handle + name + email if a user_id is present. Used after Create to
// assemble the admin notification email with the proper author block,
// and after MarkResolved to assemble the author thank-you email.
func (r *FeedbackRepo) FindByID(ctx context.Context, id string) (*model.Feedback, error) {
f := &model.Feedback{}
err := r.pg.Pool.QueryRow(ctx, `
SELECT f.id, f.user_id, f.category, 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.CategoryLabel, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail)
if err != nil {
return nil, fmt.Errorf("find feedback: %w", err)
}
return f, nil
}
// MarkAcknowledged stamps acknowledged_at = now() on a not-yet-
// acknowledged row. The `acknowledged_at IS NULL` guard makes this
// idempotent and one-way: first call returns true (newly acknowledged →
// fire the email), any later call — or an unknown id — returns false.
func (r *FeedbackRepo) MarkAcknowledged(ctx context.Context, id string) (acked bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE feedback
SET acknowledged_at = now()
WHERE id = $1 AND acknowledged_at IS NULL`, id,
)
if err != nil {
return false, fmt.Errorf("mark feedback acknowledged: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// MarkResolved stamps resolved_at = now() on a still-open row. The
// `resolved_at IS NULL` guard makes this idempotent and one-way: the
// first call returns true (newly resolved), any subsequent call — or a
// call on an unknown id — returns false so the caller knows NOT to fire
// the thank-you email a second time.
func (r *FeedbackRepo) MarkResolved(ctx context.Context, id string) (resolved bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE feedback
SET resolved_at = now()
WHERE id = $1 AND resolved_at IS NULL`, id,
)
if err != nil {
return false, fmt.Errorf("mark feedback resolved: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// ListByUser returns a single user's own feedback submissions, newest
// first, with status timestamps and any admin reply. Backs the "My
// feedback" page under /settings. No author joins — the caller IS the
// author, so handle/name/email aren't needed here. Bounded limit keeps
// the query cheap.
func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int) ([]model.Feedback, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT 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, 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,
)
if err != nil {
return nil, fmt.Errorf("query recent feedback: %w", err)
}
defer rows.Close()
out := []model.Feedback{}
for rows.Next() {
var f model.Feedback
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.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)
}
out = append(out, f)
}
return out, rows.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
}
+94
View File
@@ -0,0 +1,94 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// ErrOAuthAccountNotFound mirrors the other repo sentinels.
var ErrOAuthAccountNotFound = errors.New("oauth account not found")
// OAuthAccountRepo manages provider linkage rows.
type OAuthAccountRepo struct {
pg *Postgres
}
func NewOAuthAccountRepo(pg *Postgres) *OAuthAccountRepo {
return &OAuthAccountRepo{pg: pg}
}
// FindByProvider returns the linkage row for the given (provider, provider_user_id),
// or ErrOAuthAccountNotFound. Use this first on every callback — a hit means
// this provider identity has been seen before and we just log the user in.
func (r *OAuthAccountRepo) FindByProvider(ctx context.Context, provider, providerUserID string) (*model.OAuthAccount, error) {
const q = `
SELECT id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at
FROM oauth_accounts
WHERE provider = $1 AND provider_user_id = $2
LIMIT 1`
a := &model.OAuthAccount{}
err := r.pg.Pool.QueryRow(ctx, q, provider, providerUserID).Scan(
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrOAuthAccountNotFound
}
return nil, fmt.Errorf("find oauth account: %w", err)
}
return a, nil
}
// ListProvidersForUser returns the distinct provider names linked to this
// user, ordered alphabetically. Used by the settings UI to mark each
// social-connect row as "Connected" vs "Connect".
func (r *OAuthAccountRepo) ListProvidersForUser(ctx context.Context, userID string) ([]string, error) {
const q = `
SELECT DISTINCT provider
FROM oauth_accounts
WHERE user_id = $1
ORDER BY provider`
rows, err := r.pg.Pool.Query(ctx, q, userID)
if err != nil {
return nil, fmt.Errorf("list oauth providers: %w", err)
}
defer rows.Close()
out := []string{}
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
return nil, fmt.Errorf("scan oauth provider: %w", err)
}
out = append(out, p)
}
return out, rows.Err()
}
// Link inserts a new oauth_accounts row tying a provider identity to an
// existing user. Idempotent: a second call for the same (provider, sub)
// returns the existing row instead of erroring on the unique constraint.
func (r *OAuthAccountRepo) Link(ctx context.Context, userID, provider, providerUserID string, email, name, avatarURL *string) (*model.OAuthAccount, error) {
const q = `
INSERT INTO oauth_accounts (user_id, provider, provider_user_id, email, name, avatar_url)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (provider, provider_user_id) DO UPDATE
SET email = EXCLUDED.email,
name = EXCLUDED.name,
avatar_url = EXCLUDED.avatar_url,
updated_at = now()
RETURNING id, user_id, provider, provider_user_id, email, name, avatar_url, created_at, updated_at`
a := &model.OAuthAccount{}
err := r.pg.Pool.QueryRow(ctx, q, userID, provider, providerUserID, email, name, avatarURL).Scan(
&a.ID, &a.UserID, &a.Provider, &a.ProviderUserID,
&a.Email, &a.Name, &a.AvatarURL, &a.CreatedAt, &a.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("link oauth account: %w", err)
}
return a, nil
}
+759
View File
@@ -0,0 +1,759 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// ErrPostNotFound is returned by lookup methods when no row matches.
var ErrPostNotFound = errors.New("post not found")
// PostRepo wraps Postgres access for posts + post_images.
//
// Reshare model: a reshare is a real `posts` row owned by the resharer
// with `reshared_from_post_id` pointing at the canonical original. Likes
// and comments (future) will hang off post.id, so they diverge between
// original and reshare for free. Backgrounds are per-row in post_images,
// same story. The only thing the original and its reshares share is the
// caption / source_url / source_title at the moment of reshare.
type PostRepo struct{ pg *Postgres }
func NewPostRepo(pg *Postgres) *PostRepo { return &PostRepo{pg: pg} }
// postSelectColumns is the canonical column list used by every reader so
// every Post payload carries the same shape — including reshare
// attribution. The LEFT JOINs to `original_post` + `original_user` are
// always present; for canonical (non-reshare) rows they just yield NULL.
const postSelectColumns = `
p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
p.created_at, p.updated_at,
u.name, u.handle, u.avatar_url,
p.reshared_from_post_id,
ou.name AS original_author_name,
ou.handle AS original_author_handle,
ou.avatar_url AS original_author_avatar,
p.is_hidden
`
const postSelectFrom = `
FROM posts p
JOIN users u ON u.id = p.user_id
LEFT JOIN posts op ON op.id = p.reshared_from_post_id
LEFT JOIN users ou ON ou.id = op.user_id
`
// scanPost decodes one row produced by `postSelectColumns` into the
// destination Post. Single source of truth for the column → struct
// mapping, used by every reader.
func scanPost(row interface {
Scan(dest ...any) error
}, p *model.Post) error {
return row.Scan(
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
&p.CreatedAt, &p.UpdatedAt,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
&p.ResharedFromPostID,
&p.OriginalAuthorName, &p.OriginalAuthorHandle, &p.OriginalAuthorAvatar,
&p.IsHidden,
)
}
// Create inserts a post + its images in a single transaction so we never
// end up with orphan images.
func (r *PostRepo) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
p := &model.Post{}
err = tx.QueryRow(ctx, `
INSERT INTO posts (user_id, caption, source_url, source_title)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, caption, source_url, COALESCE(source_title, ''), created_at, updated_at`,
userID, caption, sourceURL, sourceTitle,
).Scan(&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("insert post: %w", err)
}
for i, url := range imageURLs {
var img model.PostImage
err = tx.QueryRow(ctx, `
INSERT INTO post_images (post_id, url, position)
VALUES ($1, $2, $3)
RETURNING id, url, position`,
p.ID, url, i,
).Scan(&img.ID, &img.URL, &img.Position)
if err != nil {
return nil, fmt.Errorf("insert post image %d: %w", i, err)
}
p.Images = append(p.Images, img)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return p, nil
}
// ReplaceImages atomically swaps a post's image set. Owner-only — every
// reshare is its own posts row now, so the resharer is the owner of
// their reshare and edits it just like any other post they made.
func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, imageURLs []string) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var owner string
if err := tx.QueryRow(ctx, `SELECT user_id FROM posts WHERE id = $1 FOR UPDATE`, postID).Scan(&owner); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrPostNotFound
}
return fmt.Errorf("lock post: %w", err)
}
if owner != userID {
return ErrPostNotFound
}
if _, err := tx.Exec(ctx, `DELETE FROM post_images WHERE post_id = $1`, postID); err != nil {
return fmt.Errorf("delete images: %w", err)
}
for i, url := range imageURLs {
if _, err := tx.Exec(ctx, `
INSERT INTO post_images (post_id, url, position)
VALUES ($1, $2, $3)`, postID, url, i,
); err != nil {
return fmt.Errorf("insert image %d: %w", i, err)
}
}
if _, err := tx.Exec(ctx, `UPDATE posts SET updated_at = now() WHERE id = $1`, postID); err != nil {
return fmt.Errorf("touch post: %w", err)
}
return tx.Commit(ctx)
}
// 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 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) {
return nil, ErrPostNotFound
}
return nil, fmt.Errorf("find post: %w", err)
}
imgs, err := r.ImagesFor(ctx, []string{p.ID})
if err != nil {
return nil, err
}
p.Images = imgs[p.ID]
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) {
var userID string
if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return []model.Post{}, 0, nil
}
return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
}
return r.ListByUser(ctx, userID, limit, offset)
}
// ListMentioningByHandle returns posts that tag the given user (handle),
// newest first — the "Mentions" profile tab. Same shape as the profile feed.
func (r *PostRepo) ListMentioningByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
var userID string
if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return []model.Post{}, 0, nil
}
return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
}
// Hidden posts are dropped from the mentions timeline too (the mentioned
// user is not the author, so there is no owner exception here).
var total int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM posts p JOIN 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 AND p.is_hidden = FALSE AND p.deleted_at IS NULL
ORDER BY p.created_at DESC, p.id DESC
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, userID, limit, offset)
}
// ListByUserKeyset returns the user's visible posts older than the cursor
// (created_at, id), newest-first — keyset pagination for the Instagram-style
// scroll feed. A zero beforeAt returns the newest page. No total is computed
// (infinite scroll infers "more" from a full page, like FeedForViewer). The
// (created_at, id) DESC ordering matches the tuple comparison so paging never
// skips or repeats a row even when timestamps tie.
func (r *PostRepo) ListByUserKeyset(ctx context.Context, userID string, beforeAt time.Time, beforeID string, limit int) ([]model.Post, error) {
base := `SELECT ` + postSelectColumns + postSelectFrom + `
WHERE p.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL`
if beforeAt.IsZero() {
q := base + `
ORDER BY p.created_at DESC, p.id DESC
LIMIT $2`
posts, _, err := r.collectPosts(ctx, 0, q, userID, limit)
return posts, err
}
q := base + `
AND (p.created_at < $2 OR (p.created_at = $2 AND p.id < $3))
ORDER BY p.created_at DESC, p.id DESC
LIMIT $4`
posts, _, err := r.collectPosts(ctx, 0, q, userID, beforeAt, beforeID, limit)
return posts, err
}
// ListHiddenByUser returns the owner's hidden posts, newest first — the
// /u/me "Hidden" tab. Owner-only (the caller enforces auth + scoping).
func (r *PostRepo) ListHiddenByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
total, err := r.CountHiddenByUser(ctx, userID)
if err != nil {
return nil, 0, err
}
q := `SELECT ` + postSelectColumns + postSelectFrom + `
WHERE p.user_id = $1 AND p.is_hidden = TRUE AND p.deleted_at IS NULL
ORDER BY p.created_at DESC, p.id
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, userID, limit, offset)
}
// FeedForViewer returns one page of the global home feed for `viewerID`,
// newest-first within a two-tier priority:
//
// tier 0 — posts by people the viewer follows that they haven't liked yet
// tier 1 — everything else (followed-and-liked, followers', strangers')
//
// The viewer's own posts, anything involving a block (either direction), and
// any id in `exclude` (the already-served set) are filtered out. The tier is
// computed in ORDER BY via correlated EXISTS, so no extra columns leak into
// scanPost. No total is computed (infinite scroll infers "more" from a full
// page).
//
// With a non-empty `exclude` the caller drives pagination purely by exclusion
// (pass offset 0): since the tier depends on viewer state — liking a post
// moves it between tiers — an OFFSET would drift and repeat/skip rows, so the
// seen-set is the correct cursor. Without Redis, `exclude` is empty and OFFSET
// paginates (the frontend de-dups the rare drift). At larger scale this moves
// to a precomputed fan-out / keyset cursor.
func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, offset int, exclude []string) ([]model.Post, error) {
// nil would encode as SQL NULL, and `id <> ALL(NULL)` is NULL (excludes
// every row); a non-nil empty array means "exclude nothing".
if exclude == nil {
exclude = []string{}
}
q := `SELECT ` + postSelectColumns + postSelectFrom + `
WHERE p.user_id <> $1
AND p.is_hidden = FALSE AND p.deleted_at IS NULL
AND p.id <> ALL($4)
AND NOT EXISTS (
SELECT 1 FROM user_blocks b
WHERE (b.blocker_id = $1 AND b.blocked_id = p.user_id)
OR (b.blocker_id = p.user_id AND b.blocked_id = $1)
)
ORDER BY
(CASE
WHEN EXISTS (
SELECT 1 FROM follows f
WHERE f.follower_id = $1 AND f.following_id = p.user_id
AND f.status = 'accepted'
)
AND NOT EXISTS (
SELECT 1 FROM post_likes pl
WHERE pl.post_id = p.id AND pl.user_id = $1
)
THEN 0 ELSE 1
END) ASC,
p.created_at DESC, p.id DESC
LIMIT $2 OFFSET $3`
posts, _, err := r.collectPosts(ctx, 0, q, viewerID, limit, offset, exclude)
return posts, err
}
// HydrateViewerRelationships fills ViewerFollowsAuthor / AuthorFollowsViewer /
// ViewerFollowRequested across a page of feed posts in three batched queries
// (viewer→author accepted, author→viewer accepted, viewer→author pending).
// No-op for anonymous viewers.
func (r *PostRepo) HydrateViewerRelationships(ctx context.Context, viewerID string, posts []model.Post) error {
if viewerID == "" || len(posts) == 0 {
return nil
}
seen := map[string]bool{}
authorIDs := make([]string, 0, len(posts))
for i := range posts {
id := posts[i].UserID
if id != "" && id != viewerID && !seen[id] {
seen[id] = true
authorIDs = append(authorIDs, id)
}
}
if len(authorIDs) == 0 {
return nil
}
following, err := r.followEdges(ctx,
`SELECT following_id FROM follows
WHERE follower_id = $1 AND status = 'accepted' AND following_id = ANY($2)`,
viewerID, authorIDs)
if err != nil {
return err
}
followers, err := r.followEdges(ctx,
`SELECT follower_id FROM follows
WHERE following_id = $1 AND status = 'accepted' AND follower_id = ANY($2)`,
viewerID, authorIDs)
if err != nil {
return err
}
requested, err := r.followEdges(ctx,
`SELECT following_id FROM follows
WHERE follower_id = $1 AND status = 'pending' AND following_id = ANY($2)`,
viewerID, authorIDs)
if err != nil {
return err
}
for i := range posts {
posts[i].ViewerFollowsAuthor = following[posts[i].UserID]
posts[i].AuthorFollowsViewer = followers[posts[i].UserID]
posts[i].ViewerFollowRequested = requested[posts[i].UserID]
}
return nil
}
// followEdges runs a one-column follow query and collects the ids into a set.
func (r *PostRepo) followEdges(ctx context.Context, query, viewerID string, ids []string) (map[string]bool, error) {
rows, err := r.pg.Pool.Query(ctx, query, viewerID, ids)
if err != nil {
return nil, fmt.Errorf("hydrate follow edges: %w", err)
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan follow edge: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
// Delete removes a post only if it belongs to the caller. Cascades:
// - the post's images (post_images.post_id FK)
// - any reshares of this post made by other users (posts.reshared_from_post_id FK)
// - those reshares' images (post_images.post_id FK on each reshare)
func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
tag, err := r.pg.Pool.Exec(ctx,
`DELETE FROM posts WHERE id = $1 AND user_id = $2`, postID, userID)
if err != nil {
return fmt.Errorf("delete post: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrPostNotFound
}
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 AND is_hidden = FALSE AND deleted_at IS NULL`, userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count posts: %w", err)
}
return n, nil
}
// CountHiddenByUser counts the owner's hidden posts (drives the "Hidden" tab's
// visibility + badge).
func (r *PostRepo) CountHiddenByUser(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = TRUE AND deleted_at IS NULL`, userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count hidden posts: %w", err)
}
return n, nil
}
// PostedDates returns the set of UTC calendar days, on or after `since`,
// on which the user created at least one post. Used by the sādhana streak
// to detect the "posted a quote today" discipline (and to score past days
// of the streak) without loading whole posts. Keys are "YYYY-MM-DD".
func (r *PostRepo) PostedDates(ctx context.Context, userID string, since time.Time) (map[string]bool, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT DISTINCT (created_at AT TIME ZONE 'UTC')::date AS d
FROM posts
WHERE user_id = $1 AND created_at >= $2`,
userID, since,
)
if err != nil {
return nil, fmt.Errorf("posted dates: %w", err)
}
defer rows.Close()
out := make(map[string]bool)
for rows.Next() {
var d time.Time
if err := rows.Scan(&d); err != nil {
return nil, fmt.Errorf("scan posted date: %w", err)
}
out[d.Format("2006-01-02")] = true
}
return out, rows.Err()
}
// PostCountsByDate returns, for each calendar day on or after `since`, how
// many posts the user created that day — bucketed in the given IANA timezone
// (`tz`) so the day boundary matches the user's local midnight. Keys are
// "YYYY-MM-DD"; days with no posts are absent (so a missing key reads as
// zero). The sādhana page uses this for the "posted a quote today" discipline
// and to annotate each day's fold with the number of quotes shared.
func (r *PostRepo) PostCountsByDate(ctx context.Context, userID string, since time.Time, tz string) (map[string]int, error) {
if tz == "" {
tz = "UTC"
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT (created_at AT TIME ZONE $3::text)::date AS d, count(*) AS n
FROM posts
WHERE user_id = $1 AND created_at >= $2
GROUP BY d`,
userID, since, tz,
)
if err != nil {
return nil, fmt.Errorf("post counts by date: %w", err)
}
defer rows.Close()
out := make(map[string]int)
for rows.Next() {
var (
d time.Time
n int
)
if err := rows.Scan(&d, &n); err != nil {
return nil, fmt.Errorf("scan post count: %w", err)
}
out[d.Format("2006-01-02")] = n
}
return out, rows.Err()
}
// Reshare creates a new posts row owned by `resharerID` that points at
// the canonical original via `reshared_from_post_id`. Copies the
// original's caption / source_url / source_title and its current images
// so the resharer's view renders identically at the moment of reshare;
// after that the two diverge — edits to either side never touch the
// other.
//
// If the post being reshared is itself a reshare we point at *its*
// canonical (transitive ancestor), so every reshare always references
// the true original.
//
// Idempotent: a user already resharing a canonical post returns the
// existing reshare row instead of creating a second one.
//
// Service-layer rule (caller can't reshare their own canonical) is
// enforced above this method.
func (r *PostRepo) Reshare(ctx context.Context, originalPostID, resharerID string) (*model.Post, error) {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
// Load the post being reshared. Walk one level back to canonical
// in case the caller is resharing someone else's reshare.
var (
canonicalID string
caption string
sourceURL string
sourceTitle string
canonicalUser string
)
if err := tx.QueryRow(ctx, `
SELECT
COALESCE(p.reshared_from_post_id, p.id),
p.caption, p.source_url, COALESCE(p.source_title, ''),
COALESCE(op.user_id, p.user_id)
FROM posts p
LEFT JOIN posts op ON op.id = p.reshared_from_post_id
WHERE p.id = $1`, originalPostID,
).Scan(&canonicalID, &caption, &sourceURL, &sourceTitle, &canonicalUser); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPostNotFound
}
return nil, fmt.Errorf("load canonical: %w", err)
}
if canonicalUser == resharerID {
return nil, errors.New("cannot reshare your own post")
}
// Idempotency: existing reshare by this user of this canonical?
var existingID string
switch err := tx.QueryRow(ctx, `
SELECT id FROM posts
WHERE user_id = $1 AND reshared_from_post_id = $2
LIMIT 1`, resharerID, canonicalID,
).Scan(&existingID); {
case err == nil:
// Already exists — commit the no-op tx and return the existing row.
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return r.FindByID(ctx, existingID)
case errors.Is(err, pgx.ErrNoRows):
// Fresh reshare path below.
default:
return nil, fmt.Errorf("check existing reshare: %w", err)
}
var newID string
if err := tx.QueryRow(ctx, `
INSERT INTO posts (user_id, caption, source_url, source_title, reshared_from_post_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
resharerID, caption, sourceURL, sourceTitle, canonicalID,
).Scan(&newID); err != nil {
return nil, fmt.Errorf("insert reshare post: %w", err)
}
// Copy the canonical's current images as the resharer's starting set.
if _, err := tx.Exec(ctx, `
INSERT INTO post_images (post_id, url, position)
SELECT $1, url, position
FROM post_images
WHERE post_id = $2`, newID, canonicalID,
); err != nil {
return nil, fmt.Errorf("copy images: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return r.FindByID(ctx, newID)
}
// Unreshare removes the caller's reshare of a canonical post. The
// caller passes the canonical post id (what the UI knows); we look up
// the caller's reshare-row and delete it. Idempotent — no reshare to
// delete is a no-op.
func (r *PostRepo) Unreshare(ctx context.Context, canonicalPostID, resharerID string) error {
if _, err := r.pg.Pool.Exec(ctx, `
DELETE FROM posts
WHERE user_id = $1 AND reshared_from_post_id = $2`,
resharerID, canonicalPostID,
); err != nil {
return fmt.Errorf("unreshare: %w", err)
}
return nil
}
// ReshareStatesFor returns the set of post IDs from the supplied list
// that the viewer has reshared. Used by feed renderers to flip the
// post-card "Reshare ↔ Send" toggle.
//
// `postIDs` may carry either canonical posts or reshare rows; the query
// resolves each id to its canonical first, then asks "does the viewer
// have a row whose reshared_from_post_id equals that canonical?". The
// returned map keys are the supplied ids — callers don't need to know
// the canonical relationship.
func (r *PostRepo) ReshareStatesFor(ctx context.Context, viewerID string, postIDs []string) (map[string]bool, error) {
out := make(map[string]bool, len(postIDs))
if viewerID == "" || len(postIDs) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT p.id
FROM posts p
WHERE p.id = ANY($2)
AND EXISTS (
SELECT 1 FROM posts r
WHERE r.user_id = $1
AND r.reshared_from_post_id = COALESCE(p.reshared_from_post_id, p.id)
)`, viewerID, postIDs,
)
if err != nil {
return nil, fmt.Errorf("query reshare states: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan reshare state: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
// --- internal helpers ---
func (r *PostRepo) collectPosts(ctx context.Context, total int, query string, args ...any) ([]model.Post, int, error) {
rows, err := r.pg.Pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("query posts: %w", err)
}
defer rows.Close()
posts := []model.Post{}
ids := []string{}
for rows.Next() {
var p model.Post
if err := scanPost(rows, &p); err != nil {
return nil, 0, fmt.Errorf("scan post: %w", err)
}
posts = append(posts, p)
ids = append(ids, p.ID)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterate posts: %w", err)
}
if len(ids) > 0 {
imgs, err := r.ImagesFor(ctx, ids)
if err != nil {
return nil, 0, err
}
for i := range posts {
posts[i].Images = imgs[posts[i].ID]
}
}
return posts, total, nil
}
// ImagesFor returns a map post_id → [images...] for a batch of post IDs in
// a single query so we don't N+1 the feed endpoint. Exported because the
// CollectionService needs to hydrate images onto posts that come back
// from CollectionRepo.PostsIn (which only joins posts/users — not
// post_images — to keep that query focused).
func (r *PostRepo) ImagesFor(ctx context.Context, postIDs []string) (map[string][]model.PostImage, error) {
out := make(map[string][]model.PostImage, len(postIDs))
if len(postIDs) == 0 {
return out, nil
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT post_id, id, url, position
FROM post_images
WHERE post_id = ANY($1)
ORDER BY post_id, position`, postIDs,
)
if err != nil {
return nil, fmt.Errorf("query post images: %w", err)
}
defer rows.Close()
for rows.Next() {
var postID string
var img model.PostImage
if err := rows.Scan(&postID, &img.ID, &img.URL, &img.Position); err != nil {
return nil, fmt.Errorf("scan post image: %w", err)
}
out[postID] = append(out[postID], img)
}
return out, rows.Err()
}
+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
}
+228
View File
@@ -0,0 +1,228 @@
package repository
import (
"bytes"
"context"
"errors"
"fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// R2 wraps an S3 client pointed at Cloudflare's R2 endpoint.
//
// We use R2 for avatar uploads through presigned PUT URLs:
//
// frontend asks BFF for a presigned URL ─► BFF returns URL + final public URL
// frontend PUTs the file directly to R2 ─► R2 stores it
// frontend POSTs the public URL back to BFF profile-update endpoint
//
// The BFF never proxies the file bytes; bandwidth stays browser↔R2.
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.
type R2Config struct {
AccountID string
AccessKeyID string
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.
func (c R2Config) Configured() bool {
return c.AccountID != "" && c.AccessKeyID != "" && c.SecretAccessKey != "" &&
c.Bucket != "" && c.PublicURL != ""
}
// NewR2 builds an S3 client pointed at the per-account R2 endpoint.
// Returns (nil, nil) if cfg is not Configured — the avatar route will then
// return 503 instead of panicking. That keeps the BFF bootable without R2.
func NewR2(cfg R2Config) (*R2, error) {
if !cfg.Configured() {
return nil, nil
}
endpoint := fmt.Sprintf("https://%s.r2.cloudflarestorage.com", cfg.AccountID)
creds := credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")
client := s3.New(s3.Options{
Region: "auto", // R2 ignores region but the SDK requires one
Credentials: creds,
BaseEndpoint: aws.String(endpoint),
UsePathStyle: true,
})
return &R2{
client: client,
bucket: cfg.Bucket,
publicBase: trimTrailingSlash(cfg.PublicURL),
privateBucket: cfg.PrivateBucket,
}, nil
}
// PresignPut returns a one-shot PUT URL the browser can use to upload a file
// directly to R2, plus the final public URL where the object will be served.
//
// expiry caps how long the upload URL is valid; 5 minutes is plenty and
// limits replay if the URL leaks.
func (r *R2) PresignPut(ctx context.Context, key, contentType string, expiry time.Duration) (uploadURL, publicURL string, err error) {
if r == nil {
return "", "", errors.New("R2 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.bucket),
Key: aws.String(key),
ContentType: aws.String(contentType),
}, s3.WithPresignExpires(expiry))
if err != nil {
return "", "", fmt.Errorf("presign put: %w", err)
}
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 {
if r == nil {
return ""
}
return fmt.Sprintf("%s/%s", r.publicBase, key)
}
func trimTrailingSlash(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
}
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
}
}
+570
View File
@@ -0,0 +1,570 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// social_graph_repo.go bundles the four small repos backing the social
// graph — follows, blocks, reports, and notifications. Each is a thin
// wrapper over the shared Postgres pool, mirroring the per-domain repo
// convention used elsewhere (UserRepo, PostRepo, …). They're grouped in
// one file because each is only a handful of queries and they share the
// same dependency.
// ---------------------------------------------------------------------------
// FollowRepo
// ---------------------------------------------------------------------------
// FollowRepo manages the request-based follows edge table.
type FollowRepo struct{ pg *Postgres }
func NewFollowRepo(pg *Postgres) *FollowRepo { return &FollowRepo{pg: pg} }
// Request inserts a pending follow edge follower→following. Idempotent:
// a repeated request (the edge already exists, pending or accepted) is a
// no-op. Returns created=true only when a brand-new pending row was
// inserted, so the caller fires the "X requested to follow you"
// notification exactly once.
func (r *FollowRepo) Request(ctx context.Context, followerID, followingID string) (created bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
INSERT INTO follows (follower_id, following_id, status)
VALUES ($1, $2, 'pending')
ON CONFLICT (follower_id, following_id) DO NOTHING`,
followerID, followingID,
)
if err != nil {
return false, fmt.Errorf("request follow: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// Accept flips a pending edge follower→following to accepted and stamps
// responded_at. Returns accepted=true only on the transition from
// pending (so the "X accepted your request" notification fires once); a
// call on an already-accepted or absent edge returns false.
func (r *FollowRepo) Accept(ctx context.Context, followerID, followingID string) (accepted bool, err error) {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE follows
SET status = 'accepted', responded_at = now()
WHERE follower_id = $1 AND following_id = $2 AND status = 'pending'`,
followerID, followingID,
)
if err != nil {
return false, fmt.Errorf("accept follow: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// Delete removes the edge follower→following regardless of status. Backs
// unfollow (accepted), cancel-request (own pending outgoing), reject
// (their pending incoming), and remove-follower (their accepted edge to
// me). Idempotent.
func (r *FollowRepo) Delete(ctx context.Context, followerID, followingID string) error {
_, err := r.pg.Pool.Exec(ctx, `
DELETE FROM follows WHERE follower_id = $1 AND following_id = $2`,
followerID, followingID,
)
if err != nil {
return fmt.Errorf("delete follow: %w", err)
}
return nil
}
// Status returns the status of the edge follower→following ('pending' or
// 'accepted'), or "" when no edge exists.
func (r *FollowRepo) Status(ctx context.Context, followerID, followingID string) (string, error) {
var status string
err := r.pg.Pool.QueryRow(ctx, `
SELECT status FROM follows WHERE follower_id = $1 AND following_id = $2`,
followerID, followingID,
).Scan(&status)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return "", fmt.Errorf("follow status: %w", err)
}
return status, nil
}
// CountFollowers counts accepted edges pointing at userID.
// ListFollowers returns the accepted followers of userID (users who follow
// them), newest first, optionally filtered by a name/handle search `q`.
// Returns the page plus the total matching count (for pagination).
func (r *FollowRepo) ListFollowers(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
return r.listRelations(ctx, "f.following_id = $1", "f.follower_id", "accepted", userID, q, limit, offset)
}
// ListIncomingRequests returns the users with a pending request to follow
// userID (newest first), optionally filtered by `q`, plus the total count.
func (r *FollowRepo) ListIncomingRequests(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
return r.listRelations(ctx, "f.following_id = $1", "f.follower_id", "pending", userID, q, limit, offset)
}
// ListFollowing returns the users userID follows (accepted), newest first,
// optionally filtered by `q`. Returns the page plus the total count.
func (r *FollowRepo) ListFollowing(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
return r.listRelations(ctx, "f.follower_id = $1", "f.following_id", "accepted", userID, q, limit, offset)
}
// listRelations powers follower/following/pending-request listings: `whereOwn`
// pins the row to userID on one side, `joinCol` is the follows column holding
// the *other* user (the one we join to users and return), `status` is the edge
// status to match ('accepted' or 'pending'). `q` filters by name/handle
// (ILIKE); empty `q` disables the filter. The column/status expressions are
// fixed internal constants — never user input — so this stays injection-safe.
func (r *FollowRepo) listRelations(
ctx context.Context, whereOwn, joinCol, status, userID, q string, limit, offset int,
) ([]model.UserSummary, int, error) {
like := "%" + q + "%"
var total int
countSQL := `
SELECT count(*)
FROM follows f JOIN users u ON u.id = ` + joinCol + `
WHERE ` + whereOwn + ` AND f.status = '` + status + `'
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)`
if err := r.pg.Pool.QueryRow(ctx, countSQL, userID, q, like).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count relations: %w", err)
}
if total == 0 {
return []model.UserSummary{}, 0, nil
}
listSQL := `
SELECT u.handle, u.name, u.avatar_url
FROM follows f JOIN users u ON u.id = ` + joinCol + `
WHERE ` + whereOwn + ` AND f.status = '` + status + `'
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
ORDER BY f.created_at DESC, u.handle ASC
LIMIT $4 OFFSET $5`
rows, err := r.pg.Pool.Query(ctx, listSQL, userID, q, like, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("list relations: %w", err)
}
defer rows.Close()
out := make([]model.UserSummary, 0, limit)
for rows.Next() {
var s model.UserSummary
if err := rows.Scan(&s.Handle, &s.Name, &s.AvatarURL); err != nil {
return nil, 0, fmt.Errorf("scan relation: %w", err)
}
out = append(out, s)
}
return out, total, rows.Err()
}
func (r *FollowRepo) CountFollowers(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM follows WHERE following_id = $1 AND status = 'accepted'`,
userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count followers: %w", err)
}
return n, nil
}
// CountFollowing counts accepted edges originating from userID.
func (r *FollowRepo) CountFollowing(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM follows WHERE follower_id = $1 AND status = 'accepted'`,
userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count following: %w", err)
}
return n, nil
}
// ---------------------------------------------------------------------------
// BlockRepo
// ---------------------------------------------------------------------------
// BlockRepo manages user_blocks. Block() also tears down any follow
// edges in either direction, so it runs in a transaction.
type BlockRepo struct{ pg *Postgres }
func NewBlockRepo(pg *Postgres) *BlockRepo { return &BlockRepo{pg: pg} }
// Block records blocker→blocked (idempotent) and, in the same tx, deletes
// any follow edges between the two in either direction — blocking someone
// should sever the relationship both ways.
func (r *BlockRepo) Block(ctx context.Context, blockerID, blockedID string) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin block tx: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
INSERT INTO user_blocks (blocker_id, blocked_id)
VALUES ($1, $2)
ON CONFLICT (blocker_id, blocked_id) DO NOTHING`,
blockerID, blockedID,
); err != nil {
return fmt.Errorf("insert block: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM follows
WHERE (follower_id = $1 AND following_id = $2)
OR (follower_id = $2 AND following_id = $1)`,
blockerID, blockedID,
); err != nil {
return fmt.Errorf("sever follows on block: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit block: %w", err)
}
return nil
}
// Unblock removes the blocker→blocked record. Idempotent.
func (r *BlockRepo) Unblock(ctx context.Context, blockerID, blockedID string) error {
_, err := r.pg.Pool.Exec(ctx, `
DELETE FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2`,
blockerID, blockedID,
)
if err != nil {
return fmt.Errorf("unblock: %w", err)
}
return nil
}
// BlockState reports, in one round-trip, whether a blocked b and whether
// b blocked a.
// 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
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2),
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $2 AND blocked_id = $1)`,
a, b,
).Scan(&aBlockedB, &bBlockedA)
if err != nil {
return false, false, fmt.Errorf("block state: %w", err)
}
return aBlockedB, bBlockedA, nil
}
// ---------------------------------------------------------------------------
// ReportRepo
// ---------------------------------------------------------------------------
// ReportRepo manages user_reports.
type ReportRepo struct{ pg *Postgres }
func NewReportRepo(pg *Postgres) *ReportRepo { return &ReportRepo{pg: pg} }
// Create inserts a user-report row.
func (r *ReportRepo) Create(ctx context.Context, reporterID, reportedID, category, reason string) (*model.UserReport, error) {
rep := &model.UserReport{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO user_reports (reporter_id, reported_id, category, reason)
VALUES ($1, $2, $3, $4)
RETURNING id, reporter_id, reported_id, category, reason, created_at, resolved_at`,
reporterID, reportedID, category, reason,
).Scan(&rep.ID, &rep.ReporterID, &rep.ReportedID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
if err != nil {
return nil, fmt.Errorf("create report: %w", err)
}
return rep, nil
}
// CreatePostReport inserts a post-report row.
func (r *ReportRepo) CreatePostReport(ctx context.Context, reporterID, postID, category, reason string) (*model.PostReport, error) {
rep := &model.PostReport{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO post_reports (reporter_id, post_id, category, reason)
VALUES ($1, $2, $3, $4)
RETURNING id, reporter_id, post_id, category, reason, created_at, resolved_at`,
reporterID, postID, category, reason,
).Scan(&rep.ID, &rep.ReporterID, &rep.PostID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
if err != nil {
return nil, fmt.Errorf("create post report: %w", err)
}
return rep, nil
}
// ---------------------------------------------------------------------------
// NotificationRepo
// ---------------------------------------------------------------------------
// NotificationRepo persists notification rows. Reads (list / unread
// count / mark-read) will be added when the in-app notification center
// ships; for now the table is write-only, feeding the email fan-out and
// standing ready for websocket/push.
type NotificationRepo struct{ pg *Postgres }
func NewNotificationRepo(pg *Postgres) *NotificationRepo { return &NotificationRepo{pg: pg} }
// Create inserts a notification for recipient userID, triggered by
// actorID (nil for system notifications), of the given kind.
func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *string, kind string) (*model.Notification, error) {
n := &model.Notification{}
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO notifications (user_id, actor_id, kind)
VALUES ($1, $2, $3)
RETURNING id, user_id, actor_id, kind, created_at, read_at`,
userID, actorID, kind,
).Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt)
if err != nil {
return nil, fmt.Errorf("create notification: %w", err)
}
return n, nil
}
// CreateAdminMessage persists an admin → user direct message. Unlike the
// event-driven kinds it has no actor and carries free text in `body`.
func (r *NotificationRepo) CreateAdminMessage(ctx context.Context, userID, body string) error {
if _, err := r.pg.Pool.Exec(ctx,
`INSERT INTO notifications (user_id, kind, body) VALUES ($1, 'admin_message', $2)`,
userID, body,
); err != nil {
return fmt.Errorf("create admin message notification: %w", err)
}
return nil
}
// BroadcastToAllUsers writes one notification of `kind` to every active user
// except `actorID`, in a single INSERT…SELECT. One round trip regardless of
// audience size — the alternative (a row per user from Go) would be hundreds
// of round trips on the request path.
//
// Deliberately does NOT filter on notification_prefs: ListByUser already
// excludes kinds the reader has disabled for the in_app channel at read time,
// so writing the rows unconditionally keeps a re-enabled preference able to
// surface history — exactly how the existing kinds behave.
//
// Banned accounts are skipped; they can't sign in to read it anyway.
func (r *NotificationRepo) BroadcastToAllUsers(ctx context.Context, kind, actorID string) (int64, error) {
tag, err := r.pg.Pool.Exec(ctx, `
INSERT INTO notifications (user_id, actor_id, kind)
SELECT u.id, $2, $1
FROM users u
WHERE u.id <> $2 AND u.banned_at IS NULL`, kind, actorID)
if err != nil {
return 0, fmt.Errorf("broadcast notification: %w", err)
}
return tag.RowsAffected(), nil
}
// PushRecipientsForKind lists the ids of users who should receive a *push* for
// `kind` — everyone except the actor, minus banned accounts, minus anyone who
// turned this kind off for the push channel, and minus anyone with no push
// subscription registered at all.
//
// That last filter is what keeps a broadcast cheap: only devotees who actually
// enabled web push are worth iterating, which is typically a small fraction of
// the user base.
func (r *NotificationRepo) PushRecipientsForKind(ctx context.Context, kind, actorID string) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT DISTINCT u.id
FROM users u
JOIN push_subscriptions ps ON ps.user_id = u.id
WHERE u.id <> $2 AND u.banned_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM notification_prefs np
WHERE np.user_id = u.id AND np.kind = $1
AND np.channel = 'push' AND np.enabled = FALSE)`, kind, actorID)
if err != nil {
return nil, fmt.Errorf("list push recipients: %w", err)
}
defer rows.Close()
out := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan push recipient: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
// ListByUser returns a recipient's notifications, newest first, with the
// actor's display fields joined in for rendering (nil for system
// notifications or since-deleted actors). Kinds the user has turned off for
// the in-app channel are filtered at read time — the rows still persist (they
// back like-aggregation and reappear if the pref is re-enabled).
func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count,
n.created_at, n.read_at,
u.name, u.handle, u.avatar_url,
pu.handle, n.body
FROM notifications n
LEFT JOIN users u ON u.id = n.actor_id
LEFT JOIN posts p ON p.id = n.post_id
LEFT JOIN users pu ON pu.id = p.user_id
WHERE n.user_id = $1
AND NOT EXISTS (
SELECT 1 FROM notification_prefs np
WHERE np.user_id = n.user_id AND np.kind = n.kind
AND np.channel = 'in_app' AND np.enabled = FALSE)
ORDER BY n.created_at DESC
LIMIT $2 OFFSET $3`, userID, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("list notifications: %w", err)
}
defer rows.Close()
out := []model.Notification{}
for rows.Next() {
var n model.Notification
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.PostID, &n.ActorCount,
&n.CreatedAt, &n.ReadAt,
&n.ActorName, &n.ActorHandle, &n.ActorAvatar,
&n.PostAuthorHandle, &n.Body); err != nil {
return nil, fmt.Errorf("scan notification: %w", err)
}
out = append(out, n)
}
return out, rows.Err()
}
// UpsertLike records (or aggregates) a like notification: one row per
// (recipient, post). On a repeat like it refreshes the most-recent actor,
// bumps actor_count, and re-marks the row unread + most-recent.
//
// It also throttles the aggregated like *email*: like_email_sent_at is stamped
// to now() on the first like and again only once `throttle` has elapsed since
// the last email. Returns the new actor_count and whether an email is due this
// call (i.e. the stamp was (re)set now) — decided atomically in one statement
// to avoid double-emailing under concurrent likes.
func (r *NotificationRepo) UpsertLike(ctx context.Context, userID, actorID, postID string, throttle time.Duration) (count int, emailDue bool, err error) {
secs := int(throttle.Seconds())
err = r.pg.Pool.QueryRow(ctx, `
INSERT INTO notifications (user_id, actor_id, kind, post_id, actor_count, like_email_sent_at)
VALUES ($1, $2, 'post_like', $3, 1, now())
ON CONFLICT (user_id, post_id) WHERE kind = 'post_like'
DO UPDATE SET actor_id = EXCLUDED.actor_id,
actor_count = notifications.actor_count + 1,
read_at = NULL,
created_at = now(),
like_email_sent_at = CASE
WHEN notifications.like_email_sent_at IS NULL
OR notifications.like_email_sent_at < now() - make_interval(secs => $4)
THEN now()
ELSE notifications.like_email_sent_at
END
RETURNING actor_count, (like_email_sent_at = now())`,
userID, actorID, postID, secs,
).Scan(&count, &emailDue)
if err != nil {
return 0, false, fmt.Errorf("upsert like notification: %w", err)
}
return count, emailDue, nil
}
// CreateMention persists a "you were mentioned" notification linked to the
// post. Unlike likes there's no aggregation — a mention is a deliberate,
// low-volume event.
func (r *NotificationRepo) CreateMention(ctx context.Context, userID, actorID, postID string) error {
if _, err := r.pg.Pool.Exec(ctx,
`INSERT INTO notifications (user_id, actor_id, kind, post_id)
VALUES ($1, $2, 'post_mention', $3)`,
userID, actorID, postID,
); err != nil {
return fmt.Errorf("create mention notification: %w", err)
}
return nil
}
// CreateComment persists a comment/reply notification linked to the post.
// kind is 'post_comment' (to the post author) or 'comment_reply' (to the
// replied-to comment's author). Like mentions, no aggregation.
func (r *NotificationRepo) CreateComment(ctx context.Context, userID, actorID, postID, kind string) error {
if _, err := r.pg.Pool.Exec(ctx,
`INSERT INTO notifications (user_id, actor_id, kind, post_id)
VALUES ($1, $2, $3, $4)`,
userID, actorID, kind, postID,
); err != nil {
return fmt.Errorf("create comment notification: %w", err)
}
return nil
}
// PostAuthorHandle returns the handle of the user who owns the post, for
// building a deep-link. Empty string (no error) when the post is gone.
func (r *NotificationRepo) PostAuthorHandle(ctx context.Context, postID string) (string, error) {
var h string
err := r.pg.Pool.QueryRow(ctx,
`SELECT u.handle FROM posts p JOIN users u ON u.id = p.user_id WHERE p.id = $1`,
postID).Scan(&h)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("post author handle: %w", err)
}
return h, nil
}
// CountUnread returns how many of a recipient's notifications are unread,
// excluding kinds the user has turned off for the in-app channel (matching
// ListByUser, so the badge never counts rows the feed won't show).
func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM notifications n
WHERE n.user_id = $1 AND n.read_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM notification_prefs np
WHERE np.user_id = n.user_id AND np.kind = n.kind
AND np.channel = 'in_app' AND np.enabled = FALSE)`, userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count unread notifications: %w", err)
}
return n, nil
}
// MarkAllRead stamps read_at on every still-unread notification for the
// user. Idempotent.
func (r *NotificationRepo) MarkAllRead(ctx context.Context, userID string) error {
_, err := r.pg.Pool.Exec(ctx,
`UPDATE notifications SET read_at = now() WHERE user_id = $1 AND read_at IS NULL`, userID,
)
if err != nil {
return fmt.Errorf("mark notifications read: %w", err)
}
return nil
}
+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
}
+85
View File
@@ -0,0 +1,85 @@
package repository
import (
"context"
"fmt"
"time"
)
// StatsRepo runs the aggregate count queries that power /stats. Lives
// in its own file rather than spreading COUNT methods across every
// domain repo — keeps the at-a-glance "system snapshot" queries in
// one place and one trip into the pool.
type StatsRepo struct{ pg *Postgres }
func NewStatsRepo(pg *Postgres) *StatsRepo { return &StatsRepo{pg: pg} }
// StatsSnapshot bundles totals, last-7-days counts, and feedback
// category breakdowns. One value rendered by /stats per request.
type StatsSnapshot struct {
Users int `json:"users"`
UsersLast7Days int `json:"users_last_7_days"`
Posts int `json:"posts"`
PostsLast7Days int `json:"posts_last_7_days"`
Collections int `json:"collections"`
PublicCollections int `json:"public_collections"`
Bookmarks int `json:"bookmarks"`
Reshares int `json:"reshares"`
Feedback int `json:"feedback"`
FeedbackLast7Days int `json:"feedback_last_7_days"`
FeedbackByCategory []CategoryCount `json:"feedback_by_category"`
}
type CategoryCount struct {
Category string `json:"category"`
Count int `json:"count"`
}
// Snapshot runs every count needed by /stats. Queries run in series —
// the table sizes are modest enough that chasing parallelism here
// would buy sub-ms wins for added complexity.
func (r *StatsRepo) Snapshot(ctx context.Context) (*StatsSnapshot, error) {
// 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 {
sql string
args []any
dst *int
}{
{`SELECT COUNT(*) FROM users`, nil, &s.Users},
{`SELECT COUNT(*) FROM users WHERE created_at >= $1`, []any{sevenAgo}, &s.UsersLast7Days},
{`SELECT COUNT(*) FROM posts`, nil, &s.Posts},
{`SELECT COUNT(*) FROM posts WHERE created_at >= $1`, []any{sevenAgo}, &s.PostsLast7Days},
{`SELECT COUNT(*) FROM collections`, nil, &s.Collections},
{`SELECT COUNT(*) FROM collections WHERE is_public`, nil, &s.PublicCollections},
{`SELECT COUNT(*) FROM bookmarks`, nil, &s.Bookmarks},
{`SELECT COUNT(*) FROM posts WHERE reshared_from_post_id IS NOT NULL`, nil, &s.Reshares},
{`SELECT COUNT(*) FROM feedback`, nil, &s.Feedback},
{`SELECT COUNT(*) FROM feedback WHERE created_at >= $1`, []any{sevenAgo}, &s.FeedbackLast7Days},
}
for _, q := range scalars {
if err := r.pg.Pool.QueryRow(ctx, q.sql, q.args...).Scan(q.dst); err != nil {
return nil, fmt.Errorf("stats scalar %q: %w", q.sql, err)
}
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT category, COUNT(*)
FROM feedback
GROUP BY category
ORDER BY COUNT(*) DESC, category ASC`)
if err != nil {
return nil, fmt.Errorf("feedback by category: %w", err)
}
defer rows.Close()
for rows.Next() {
var cc CategoryCount
if err := rows.Scan(&cc.Category, &cc.Count); err != nil {
return nil, fmt.Errorf("scan category: %w", err)
}
s.FeedbackByCategory = append(s.FeedbackByCategory, cc)
}
return s, rows.Err()
}
+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 {
+649
View File
@@ -0,0 +1,649 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// ErrUserNotFound is returned by lookup methods when no row matches.
var ErrUserNotFound = errors.New("user not found")
// ErrEmailTaken is returned by Create when the email already exists.
var ErrEmailTaken = errors.New("email already registered")
// ErrHandleTaken is returned when the chosen handle collides with an existing user.
var ErrHandleTaken = errors.New("handle already in use")
// UserRepo provides CRUD on the users table.
type UserRepo struct {
pg *Postgres
}
func NewUserRepo(pg *Postgres) *UserRepo {
return &UserRepo{pg: pg}
}
// userColumns is the canonical column list for SELECT/RETURNING on the
// users table, in the exact order scanUser reads them. Keeping the two in
// lockstep here is what makes adding a column a single-edit change instead
// of a hunt across every query.
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at, country,
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
preferred_chanting_time, peak_active_time, banned_at,
bookmark_limit, public_collection_limit, private_collection_limit,
disciple_id, bio`
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
// scanUser works for single-row and iterated reads alike.
type rowScanner interface {
Scan(dest ...any) error
}
// scanUser reads one users row (selected via userColumns) into u.
func scanUser(row rowScanner, u *model.User) error {
return row.Scan(
&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
&u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt,
&u.BookmarkLimit, &u.PublicCollectionLimit, &u.PrivateCollectionLimit,
&u.DiscipleID, &u.Bio,
)
}
// ProfileUpdate is the set of fields UpdateProfile can change. Every field
// is a pointer so "leave unchanged" (nil) is distinct from "set". For the
// nullable string fields, a non-nil pointer to "" means "clear to NULL";
// DailyChantingRounds uses *0 as its clear sentinel (1 is the real min).
type ProfileUpdate struct {
Name *string
AvatarURL *string
Handle *string
Bio *string // nil=leave, ""=clear, text=set
Country *string
Sampradaya *string // nil=leave, ""=clear, code=set
Gender *string
Initiated *bool
InitiatedAt *string // "YYYY-MM-DD" to set, "" to clear
DailyChantingRounds *int // 0 to clear
PreferredChantingTime *string
PeakActiveTime *string
}
// Create inserts a new user. Returns ErrEmailTaken or ErrHandleTaken on
// unique-violation. The caller is responsible for picking a unique handle —
// the auth service does this with a retry loop.
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name, handle string) (*model.User, error) {
q := `
INSERT INTO users (email, password_hash, name, handle)
VALUES ($1, $2, $3, $4)
RETURNING ` + userColumns
u := &model.User{}
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
strings.ToLower(strings.TrimSpace(email)), passwordHash, name, handle,
), u)
if err != nil {
if isUniqueViolation(err, "users_handle_key") {
return nil, ErrHandleTaken
}
if isUniqueViolation(err, "users_email_key") {
return nil, ErrEmailTaken
}
return nil, fmt.Errorf("create user: %w", err)
}
return u, nil
}
// FindByEmail returns the user with the given email, or ErrUserNotFound.
func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User, error) {
return r.findOne(ctx,
`WHERE email = $1`, strings.ToLower(strings.TrimSpace(email)))
}
// CreateOAuth inserts a user with a NULL password_hash — used by the OAuth
// sign-in flow when there's no matching email yet. Email is nullable here:
// providers that don't return one (e.g. phone-only Facebook accounts) end
// up as users with email=NULL until they fill it in via profile settings.
// Same handle-collision retry contract as Create.
func (r *UserRepo) CreateOAuth(ctx context.Context, email *string, name, handle string, avatarURL *string, emailVerified bool) (*model.User, error) {
q := `
INSERT INTO users (email, password_hash, name, handle, avatar_url, email_verified_at)
VALUES ($1, NULL, $2, $3, $4, CASE WHEN $5::boolean THEN now() ELSE NULL END)
RETURNING ` + userColumns
var normEmail *string
if email != nil {
v := strings.ToLower(strings.TrimSpace(*email))
if v != "" {
normEmail = &v
}
}
u := &model.User{}
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
normEmail, name, handle, avatarURL, emailVerified,
), u)
if err != nil {
if isUniqueViolation(err, "users_handle_key") {
return nil, ErrHandleTaken
}
if isUniqueViolation(err, "users_email_key") {
return nil, ErrEmailTaken
}
return nil, fmt.Errorf("create oauth user: %w", err)
}
return u, nil
}
// FindByID returns the user with the given id, or ErrUserNotFound.
func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) {
return r.findOne(ctx, `WHERE id = $1`, id)
}
// FindByHandle returns the user with the given handle (case-insensitive
// thanks to the citext column), or ErrUserNotFound.
func (r *UserRepo) FindByHandle(ctx context.Context, handle string) (*model.User, error) {
return r.findOne(ctx, `WHERE handle = $1`, strings.TrimSpace(handle))
}
func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) {
q := `SELECT ` + userColumns + ` FROM users ` + where + ` LIMIT 1`
u := &model.User{}
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("find user: %w", err)
}
return u, nil
}
// SetEmail fills in (or replaces) a user's email and resets email_verified_at
// to NULL so a fresh verification flow can run. Returns ErrEmailTaken if
// another row already owns this address.
//
// Used by the "add your email later" flow for OAuth users who signed up
// without one. The caller (auth service) is expected to follow up by
// minting and sending a verification token.
func (r *UserRepo) SetEmail(ctx context.Context, id, email string) (*model.User, error) {
q := `
UPDATE users
SET email = $2,
email_verified_at = NULL,
updated_at = now()
WHERE id = $1
RETURNING ` + userColumns
u := &model.User{}
err := scanUser(r.pg.Pool.QueryRow(ctx, q, id, strings.ToLower(strings.TrimSpace(email))), u)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
if isUniqueViolation(err, "users_email_key") {
return nil, ErrEmailTaken
}
return nil, fmt.Errorf("set email: %w", err)
}
return u, nil
}
// UpdateProfile applies a partial profile patch. Only the non-nil fields of
// `p` are touched; the SQL is built dynamically so an untouched column is
// never written (keeps updated_at honest and the patch minimal). For the
// nullable string fields a non-nil "" clears the column to NULL.
//
// `handle` collisions surface as ErrHandleTaken so the handler can render
// a useful message.
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, p ProfileUpdate) (*model.User, error) {
sets := make([]string, 0, 10)
args := []any{id}
// set appends "col = $N" with a bound value.
set := func(col string, val any) {
args = append(args, val)
sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args)))
}
// setExpr appends a parameterless SET clause (e.g. "col = NULL").
setExpr := func(expr string) { sets = append(sets, expr) }
// nullableString handles the nil=leave / ""=clear / value=set trio.
nullableString := func(col string, v *string) {
if v == nil {
return
}
if *v == "" {
setExpr(col + " = NULL")
} else {
set(col, *v)
}
}
if p.Name != nil {
set("name", *p.Name)
}
if p.Handle != nil {
set("handle", *p.Handle)
}
nullableString("avatar_url", p.AvatarURL)
nullableString("bio", p.Bio)
nullableString("country", p.Country)
nullableString("sampradaya", p.Sampradaya)
nullableString("gender", p.Gender)
nullableString("preferred_chanting_time", p.PreferredChantingTime)
nullableString("peak_active_time", p.PeakActiveTime)
if p.Initiated != nil {
set("initiated", *p.Initiated)
}
if p.InitiatedAt != nil {
if *p.InitiatedAt == "" {
setExpr("initiated_at = NULL")
} else {
args = append(args, *p.InitiatedAt)
sets = append(sets, fmt.Sprintf("initiated_at = $%d::date", len(args)))
}
}
if p.DailyChantingRounds != nil {
if *p.DailyChantingRounds == 0 {
setExpr("daily_chanting_rounds = NULL")
} else {
set("daily_chanting_rounds", *p.DailyChantingRounds)
}
}
// Nothing to change — return the current row rather than a no-op UPDATE.
if len(sets) == 0 {
return r.findOne(ctx, `WHERE id = $1`, id)
}
sets = append(sets, "updated_at = now()")
q := `UPDATE users SET ` + strings.Join(sets, ", ") + ` WHERE id = $1 RETURNING ` + userColumns
u := &model.User{}
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
if isUniqueViolation(err, "users_handle_key") {
return nil, ErrHandleTaken
}
return nil, fmt.Errorf("update profile: %w", err)
}
return u, nil
}
// SetCountryIfEmpty fills in the account country only when it's still
// NULL — used by the best-effort signup capture, so a later manual edit
// (or a second capture) never clobbers a value the user chose. `cc` is an
// ISO-3166-1 alpha-2 code; callers pass "" to no-op. Errors are the
// caller's to swallow (capture is fire-and-forget).
func (r *UserRepo) SetCountryIfEmpty(ctx context.Context, id, cc string) error {
cc = strings.ToUpper(strings.TrimSpace(cc))
if len(cc) != 2 {
return nil
}
_, err := r.pg.Pool.Exec(ctx, `
UPDATE users SET country = $2, updated_at = now()
WHERE id = $1 AND country IS NULL`, id, cc)
if err != nil {
return fmt.Errorf("set country: %w", err)
}
return nil
}
// UpdatePassword sets a new password hash and bumps token_version so any
// existing JWTs immediately become invalid.
func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error {
const q = `
UPDATE users
SET password_hash = $2,
token_version = token_version + 1,
updated_at = now()
WHERE id = $1`
tag, err := r.pg.Pool.Exec(ctx, q, id, newHash)
if err != nil {
return fmt.Errorf("update password: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrUserNotFound
}
return nil
}
// Search returns users whose name or handle matches `q` (ILIKE
// substring). Leading "@" is stripped — callers can pass either
// "@foo" or "foo". Ordering bubbles handle-prefix matches above
// name-prefix above the rest, then alphabetic by name as the
// tiebreaker.
//
// Postgres ILIKE is fine at our user-table scale (low six figures
// max). When the table grows enough to warrant fuzzy / trigram
// matching, add a pg_trgm GIN index and switch to similarity().
func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]model.User, int, error) {
cleaned := strings.TrimPrefix(strings.TrimSpace(q), "@")
if cleaned == "" {
return []model.User{}, 0, nil
}
if limit <= 0 || limit > 50 {
limit = 20
}
if offset < 0 {
offset = 0
}
substring := "%" + cleaned + "%"
prefix := cleaned + "%"
// Total matches for pagination — same WHERE as the page query. Cheap
// enough at our user-table scale; revisit alongside the pg_trgm switch.
var total int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM users
WHERE (name ILIKE $1 OR handle::text ILIKE $1) 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)
-- 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,
handle
LIMIT $3 OFFSET $4`
rows, err := r.pg.Pool.Query(ctx, sql, substring, prefix, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("search users: %w", err)
}
defer rows.Close()
out := []model.User{}
for rows.Next() {
var u model.User
if err := scanUser(rows, &u); err != nil {
return nil, 0, fmt.Errorf("scan user: %w", err)
}
out = append(out, u)
}
return out, total, rows.Err()
}
// AllHandles returns every user's handle, for the public-profile sitemap.
// Projection-only (handle is unique + indexed), cheap to sweep. Excludes the
// reserved "me" handle (the signed-in user's own private library route).
func (r *UserRepo) AllHandles(ctx context.Context) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx,
`SELECT handle FROM users WHERE handle <> '' AND handle::text <> 'me' ORDER BY handle`)
if err != nil {
return nil, fmt.Errorf("list handles: %w", err)
}
defer rows.Close()
handles := []string{}
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
return nil, fmt.Errorf("scan handle: %w", err)
}
handles = append(handles, h)
}
return handles, rows.Err()
}
// DiscipleLink pairs a Mongo disciple id with the platform account an admin
// has linked to it. Powers the /disciples browser highlight: the reader page
// fetches the whole (small) set once and marks the matching cards.
type DiscipleLink struct {
DiscipleID string `json:"disciple_id"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
}
// ListDiscipleLinks returns every user→disciple link. Deliberately unpaged:
// only Śrīla Prabhupāda's own disciples are ever linked, so this is dozens of
// rows at most and the partial index on disciple_id keeps it a cheap scan.
// Suspended accounts are excluded so a banned user can't keep a public badge.
func (r *UserRepo) ListDiscipleLinks(ctx context.Context) ([]DiscipleLink, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT disciple_id, handle, name, avatar_url
FROM users
WHERE disciple_id IS NOT NULL AND banned_at IS NULL
ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("list disciple links: %w", err)
}
defer rows.Close()
out := []DiscipleLink{}
for rows.Next() {
var l DiscipleLink
if err := rows.Scan(&l.DiscipleID, &l.Handle, &l.Name, &l.AvatarURL); err != nil {
return nil, fmt.Errorf("scan disciple link: %w", err)
}
out = append(out, l)
}
return out, rows.Err()
}
// RecentlyJoined returns up to `limit` users who registered on/after `since`,
// newest first, as a discovery list for the feed's "new on the platform"
// strip. Excludes the viewer, anyone the viewer already has a follow edge with
// (pending or accepted), and anyone involved in a block either direction — so
// every suggestion is a fresh account the viewer can actually follow.
func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since time.Time, limit int) ([]model.UserSummary, error) {
if limit <= 0 || limit > 20 {
limit = 5
}
const q = `
SELECT u.id, u.handle, u.name, u.avatar_url
FROM users u
WHERE u.created_at >= $2
AND u.id <> $1
-- 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)
}
return out, rows.Err()
}
// GetReadingPrefs returns the raw JSONB bytes for the user's reading
// prefs (empty object when never set). The frontend owns the shape, so
// we round-trip the payload verbatim — no server-side schema enforced.
func (r *UserRepo) GetReadingPrefs(ctx context.Context, id string) ([]byte, error) {
const q = `SELECT reading_prefs FROM users WHERE id = $1 LIMIT 1`
var raw []byte
err := r.pg.Pool.QueryRow(ctx, q, id).Scan(&raw)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("get reading prefs: %w", err)
}
if len(raw) == 0 {
raw = []byte("{}")
}
return raw, nil
}
// UpdateReadingPrefs overwrites the user's reading prefs blob. Caller is
// responsible for validating that `prefsJSON` is well-formed JSON (the
// handler does this by re-marshalling the decoded struct before calling
// in).
func (r *UserRepo) UpdateReadingPrefs(ctx context.Context, id string, prefsJSON []byte) error {
const q = `UPDATE users SET reading_prefs = $2::jsonb, updated_at = now() WHERE id = $1`
tag, err := r.pg.Pool.Exec(ctx, q, id, prefsJSON)
if err != nil {
return fmt.Errorf("update reading prefs: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrUserNotFound
}
return nil
}
// MarkEmailVerified sets email_verified_at = now() if not already set.
func (r *UserRepo) MarkEmailVerified(ctx context.Context, id string) error {
const q = `UPDATE users SET email_verified_at = now(), updated_at = now()
WHERE id = $1 AND email_verified_at IS NULL`
_, err := r.pg.Pool.Exec(ctx, q, id)
if err != nil {
return fmt.Errorf("mark verified: %w", err)
}
return nil
}
// Helper for unique-violation detection. Avoids importing pgconn just for
// the SQLSTATE; we string-match on the message which contains the constraint
// name. Good enough for the one constraint we care about.
func isUniqueViolation(err error, constraint string) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "23505") || strings.Contains(msg, constraint)
}
// AuthTokenRepo handles password_reset + email_verification tokens.
type AuthTokenRepo struct {
pg *Postgres
}
func NewAuthTokenRepo(pg *Postgres) *AuthTokenRepo {
return &AuthTokenRepo{pg: pg}
}
// Create inserts a new token row. `tokenHash` must be the SHA-256 hex of the
// raw token; only the hash is stored, never the plaintext.
func (r *AuthTokenRepo) Create(ctx context.Context, userID, purpose, tokenHash string, ttl time.Duration) (*model.AuthToken, error) {
const q = `
INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, purpose, token_hash, expires_at, used_at, created_at`
t := &model.AuthToken{}
err := r.pg.Pool.QueryRow(ctx, q, userID, purpose, tokenHash, time.Now().Add(ttl)).
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
if err != nil {
return nil, fmt.Errorf("create auth_token: %w", err)
}
return t, nil
}
// FindValid returns a non-used, non-expired token by hash + purpose.
func (r *AuthTokenRepo) FindValid(ctx context.Context, tokenHash, purpose string) (*model.AuthToken, error) {
const q = `
SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at
FROM auth_tokens
WHERE token_hash = $1
AND purpose = $2
AND used_at IS NULL
AND expires_at > now()
LIMIT 1`
t := &model.AuthToken{}
err := r.pg.Pool.QueryRow(ctx, q, tokenHash, purpose).
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("find token: %w", err)
}
return t, nil
}
// FindValidForUser returns a non-used, non-expired token by user + hash +
// purpose. Used by the OTP login flow where the keyspace is small (6
// digits) — scoping by user_id prevents a global hash-collision lookup.
func (r *AuthTokenRepo) FindValidForUser(ctx context.Context, userID, tokenHash, purpose string) (*model.AuthToken, error) {
const q = `
SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at
FROM auth_tokens
WHERE user_id = $1
AND token_hash = $2
AND purpose = $3
AND used_at IS NULL
AND expires_at > now()
LIMIT 1`
t := &model.AuthToken{}
err := r.pg.Pool.QueryRow(ctx, q, userID, tokenHash, purpose).
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("find token: %w", err)
}
return t, nil
}
// Consume marks a token as used. After this call the token can never be
// honoured again, even if it has not yet expired.
func (r *AuthTokenRepo) Consume(ctx context.Context, id string) error {
const q = `UPDATE auth_tokens SET used_at = now() WHERE id = $1 AND used_at IS NULL`
_, err := r.pg.Pool.Exec(ctx, q, id)
if err != nil {
return fmt.Errorf("consume token: %w", err)
}
return nil
}
// InvalidatePending marks all not-yet-used tokens for a user+purpose as used.
// Called when a fresh token is issued so old links are immediately dead.
func (r *AuthTokenRepo) InvalidatePending(ctx context.Context, userID, purpose string) error {
const q = `UPDATE auth_tokens SET used_at = now()
WHERE user_id = $1 AND purpose = $2 AND used_at IS NULL`
_, err := r.pg.Pool.Exec(ctx, q, userID, purpose)
if err != nil {
return fmt.Errorf("invalidate pending tokens: %w", err)
}
return nil
}
+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
}
+907 -28
View File
File diff suppressed because it is too large Load Diff
+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)
}
+837
View File
@@ -0,0 +1,837 @@
// Package service orchestrates the auth flows on top of the repositories.
//
// The handler layer is intentionally dumb: it parses the request, calls one
// AuthService method, and renders the result. All branching (already exists?
// wrong password? expired token?) lives here, behind a small set of named
// error sentinels.
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"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"
)
// Sentinel errors. Handlers map these to HTTP status codes.
var (
ErrInvalidCredentials = errors.New("invalid email or password")
ErrEmailAlreadyTaken = errors.New("email already registered")
ErrUserNotFound = errors.New("user not found")
ErrInvalidOrExpiredLink = errors.New("link is invalid or has expired")
ErrEmailAlreadyVerified = errors.New("email already verified")
ErrWeakPassword = errors.New("password must be at least 8 characters")
ErrHandleTaken = errors.New("handle already in use")
ErrAccountSuspended = errors.New("this account has been suspended")
)
// AuthService bundles every flow that mutates auth state.
type AuthService struct {
users *repository.UserRepo
tokens *repository.AuthTokenRepo
oauth *repository.OAuthAccountRepo // optional — nil disables OAuth flows
jwt *auth.Issuer
mail mailer.Mailer
logger *zap.Logger
frontend string // FrontendURL — used to build reset / verify links
reval *revalidate.Client // optional — pings sitemap revalidation on new signups
// TTLs — surfaced as fields so tests can shrink them.
PasswordResetTTL time.Duration
EmailVerifyTTL time.Duration
}
// SetRevalidator wires the frontend cache-revalidation client. Optional —
// when unset, new-signup sitemap pings are simply skipped.
func (s *AuthService) SetRevalidator(r *revalidate.Client) { s.reval = r }
// NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if
// the OAuth flows aren't wired (handler then returns 503).
func NewAuthService(
users *repository.UserRepo,
tokens *repository.AuthTokenRepo,
oauthRepo *repository.OAuthAccountRepo,
jwt *auth.Issuer,
mail mailer.Mailer,
logger *zap.Logger,
frontendURL string,
) *AuthService {
return &AuthService{
users: users,
tokens: tokens,
oauth: oauthRepo,
jwt: jwt,
mail: mail,
logger: logger,
frontend: strings.TrimRight(frontendURL, "/"),
PasswordResetTTL: 1 * time.Hour,
EmailVerifyTTL: 24 * time.Hour,
}
}
// CaptureCountry records the account's country only if it isn't already
// set. Best-effort: a blank code is a no-op, and DB errors are logged but
// swallowed so the caller (a post-signup goroutine) never fails on it.
func (s *AuthService) CaptureCountry(ctx context.Context, userID, country string) {
if strings.TrimSpace(country) == "" {
return
}
if err := s.users.SetCountryIfEmpty(ctx, userID, country); err != nil {
s.logger.Warn("capture signup country failed",
zap.String("user_id", userID), zap.Error(err))
}
}
// AuthResult is the value handlers return on signup/login. Token is the raw
// JWT the handler will plant in the response cookie; the User is the public
// profile to ship in the response body.
type AuthResult struct {
User *model.User
Token string
Expires time.Time
}
// SignUp creates a new user, issues a session JWT, and fires off a
// verification email (best-effort — failure to send does not block signup,
// since the user can hit resend later).
func (s *AuthService) SignUp(ctx context.Context, email, password, name string) (*AuthResult, error) {
if len(password) < 8 {
return nil, ErrWeakPassword
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
// Generate a handle and retry up to 8 times if it collides with an
// existing user. The slug seed is derived from the email's local-part,
// which is the closest thing to a free-form identifier the user gave us.
base := slugForHandle(email)
var u *model.User
for attempt := 0; attempt < 8; attempt++ {
handle := base
if attempt > 0 {
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
}
u, err = s.users.Create(ctx, email, string(hash), strings.TrimSpace(name), handle)
if err == nil {
break
}
if errors.Is(err, repository.ErrEmailTaken) {
return nil, ErrEmailAlreadyTaken
}
if !errors.Is(err, repository.ErrHandleTaken) {
return nil, err
}
}
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
if err := s.sendVerificationEmail(ctx, u); err != nil {
// Log and keep going — signup itself succeeded.
s.logger.Warn("verification email failed", zap.String("user", u.ID), zap.Error(err))
}
// New public profile → refresh the sitemap so it's crawlable promptly.
s.reval.Trigger("sitemap")
return s.issueSession(u)
}
// LoginResult is the two-state outcome of LogIn:
//
// - `Auth != nil` — credentials verified AND email is fine. Caller
// sets the session cookie and returns the user.
// - `OTPRequired == true` — credentials verified but the account has been
// unverified for more than 30 days. An OTP has been emailed; the caller
// must collect it from the user and call VerifyLoginOTP to complete the
// sign-in. No session cookie is set yet.
//
// One of the two is always set on a non-error return.
type LoginResult struct {
Auth *AuthResult
OTPRequired bool
}
// staleUnverifiedAge is how long an account can stay unverified before
// every subsequent email/password login is gated on an OTP. Anything
// shorter and a normal user who simply hasn't checked their inbox yet
// would hit the gate; longer and abandoned-but-not-verified accounts
// remain a forever-credential. 30 days is the documented requirement.
const staleUnverifiedAge = 30 * 24 * time.Hour
// LogIn authenticates a user by email + password. The error returned for an
// unknown email is intentionally identical to a wrong password — never leak
// which one was wrong. May return early with OTPRequired=true; see LoginResult.
func (s *AuthService) LogIn(ctx context.Context, email, password string) (*LoginResult, error) {
u, err := s.users.FindByEmail(ctx, email)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
return nil, ErrInvalidCredentials
}
return nil, err
}
// OAuth-only accounts have no password hash. Treat them as a credential
// mismatch so we don't reveal that an OAuth user exists for this email.
if u.PasswordHash == nil {
return nil, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
// Suspended accounts can't sign in (checked after the password so we don't
// reveal ban status to someone who doesn't know the password).
if u.BannedAt != nil {
return nil, ErrAccountSuspended
}
// OTP gate — unverified email AND account is older than the grace
// window. Mint an OTP and signal the caller to collect it. We do NOT
// issue a session here.
if u.EmailVerifiedAt == nil && time.Since(u.CreatedAt) > staleUnverifiedAge {
if u.Email == nil || *u.Email == "" {
// No email to send to → fall through to "invalid credentials"
// rather than expose this corner case.
return nil, ErrInvalidCredentials
}
if err := s.sendLoginOTP(ctx, u); err != nil {
s.logger.Warn("login otp send failed",
zap.String("user", u.ID), zap.Error(err))
return nil, fmt.Errorf("send login otp: %w", err)
}
return &LoginResult{OTPRequired: true}, nil
}
res, err := s.issueSession(u)
if err != nil {
return nil, err
}
return &LoginResult{Auth: res}, nil
}
// VerifyLoginOTP completes a login that was paused by the stale-unverified
// OTP gate. Re-checks the password (so an attacker who steals an OTP email
// still can't sign in without the password), validates the OTP, flips
// email_verified_at to now(), and issues a fresh session.
func (s *AuthService) VerifyLoginOTP(ctx context.Context, email, password, otp string) (*AuthResult, error) {
u, err := s.users.FindByEmail(ctx, email)
if err != nil {
return nil, ErrInvalidCredentials
}
if u.PasswordHash == nil {
return nil, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
if u.BannedAt != nil {
return nil, ErrAccountSuspended
}
otpHash := hashToken(otp)
tok, err := s.tokens.FindValidForUser(ctx, u.ID, otpHash, model.PurposeLoginOTP)
if err != nil {
return nil, ErrInvalidOrExpiredLink
}
if err := s.tokens.Consume(ctx, tok.ID); err != nil {
return nil, err
}
if err := s.users.MarkEmailVerified(ctx, u.ID); err != nil {
return nil, err
}
u2, err := s.users.FindByID(ctx, u.ID)
if err != nil {
return nil, err
}
return s.issueSession(u2)
}
// sendLoginOTP mints a fresh 6-digit code, invalidates any older pending
// codes, stores the SHA-256 hash, and emails the raw code to the user.
func (s *AuthService) sendLoginOTP(ctx context.Context, u *model.User) error {
if u.Email == nil || *u.Email == "" {
return errors.New("no email on file")
}
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeLoginOTP)
otp, hash, err := newOTP()
if err != nil {
return err
}
if _, err := s.tokens.Create(ctx, u.ID, model.PurposeLoginOTP, hash, 10*time.Minute); err != nil {
return err
}
body := loginOTPEmail(u.Name, otp)
return s.mail.Send(*u.Email, "Your login code — Prabhupāda Says", body)
}
// newOTP returns a freshly-generated 6-digit code + its SHA-256 hex hash.
// Modulo bias on a uniform 32-bit source is negligible for the 1M keyspace
// (10⁶ / 2³² ≈ 4 parts in 10⁴) and irrelevant against rate-limited online
// guessing, which is the only attack model we care about for this size.
func newOTP() (raw, hash string, err error) {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return "", "", err
}
n := binary.BigEndian.Uint32(b[:]) % 1_000_000
raw = fmt.Sprintf("%06d", n)
hash = hashToken(raw)
return raw, hash, nil
}
// OAuthProfile is the provider-agnostic profile shape consumed by
// SignInWithOAuth. Mirrors pkg/oauth.Profile but lives here so the service
// layer doesn't import the OAuth package (which would create a layering
// cycle once handlers compose both).
type OAuthProfile struct {
Provider string
ProviderUserID string
Email string
EmailVerified bool
Name string
AvatarURL string
}
// ErrOAuthUnavailable means the OAuth repo wasn't wired (no Postgres at
// startup). Handler maps to 503.
var ErrOAuthUnavailable = errors.New("oauth service unavailable")
// ErrOAuthAccountClaimed is returned by LinkOAuthToCurrentUser when the
// provider identity is already linked to a different user. We refuse to
// move the linkage — the rightful owner of the OAuth account at the
// provider can sign in via the normal flow.
var ErrOAuthAccountClaimed = errors.New("oauth account already linked to another user")
// LinkOAuthToCurrentUser ties an OAuth provider identity to a specific
// already-authenticated user (settings → Connect flow). Refuses to move
// the linkage if some other user already claims it.
//
// Email policy: the user's primary email is NEVER touched by this call.
// The provider's email is stored on the oauth_accounts row only for
// reference (display in the future, audit). If the user wants to change
// their primary email they have to do it explicitly via SetEmail.
func (s *AuthService) LinkOAuthToCurrentUser(ctx context.Context, userID string, prof OAuthProfile) error {
if s.oauth == nil {
return ErrOAuthUnavailable
}
// If this (provider, sub) is already linked, the only valid case is
// that it's linked to the SAME user we're trying to link to. Any other
// user owning this linkage means the OAuth account was already claimed
// (typically the user mis-clicked or it's a different person on a
// shared device) — refuse rather than silently steal it.
if existing, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
if existing.UserID != userID {
return ErrOAuthAccountClaimed
}
// Same user — idempotent: refresh the denormalised profile fields
// and return success.
_, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
return err
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
return err
}
// Fresh link.
_, err := s.oauth.Link(ctx, userID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
return err
}
// SignInWithOAuth turns a provider profile into a session. Three-step lookup:
//
// 1. Existing linkage on (provider, provider_user_id) → log that user in.
// 2. Provider gave us an email AND an existing user has the same email →
// link this provider to them, log in.
// 3. Otherwise create a new user (no password, email_verified iff we have
// an email) and link.
//
// When the provider returns no email (Facebook accounts with no verified
// email on file, accounts that declined the email scope, etc.) we skip
// the merge step and create the user with email=NULL. They can add an
// email later via PATCH /users/me — the frontend prompts them.
//
// When we DO have an email, every successful OAuth round-trip is treated
// as proof the user controls it — Google + Facebook both verify email
// ownership before returning it, so we mark the email as verified.
//
// In every case the resulting user gets a fresh JWT just like LogIn.
func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*AuthResult, error) {
if s.oauth == nil {
return nil, ErrOAuthUnavailable
}
// 1. Known linkage.
if a, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
u, err := s.users.FindByID(ctx, a.UserID)
if err != nil {
return nil, err
}
// Refresh denormalised fields on the linkage row (display name /
// avatar may have changed at the provider).
_, _ = s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
if prof.Email != "" {
s.ensureEmailVerified(ctx, u)
}
return s.issueSession(u)
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
return nil, err
}
// 2. Existing user with the same email → link.
// Only attempt this when the provider actually gave us an email. With
// no email there's no merge key — we just create a fresh user below.
if prof.Email != "" {
if u, err := s.users.FindByEmail(ctx, prof.Email); err == nil {
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
return nil, err
}
s.ensureEmailVerified(ctx, u)
return s.issueSession(u)
} else if !errors.Is(err, repository.ErrUserNotFound) {
return nil, err
}
}
// 3. Brand new user. Reuse the handle-collision retry loop.
displayName := prof.Name
if displayName == "" && prof.Email != "" {
displayName = strings.SplitN(prof.Email, "@", 2)[0]
}
if displayName == "" {
// No name AND no email — fall back to the provider name so the user
// at least has a non-empty display name they can rename later.
displayName = strings.Title(prof.Provider) + " user" //nolint:staticcheck // legacy Title is fine for ASCII labels
}
base := slugForHandle(prof.Email)
if base == "user" && prof.ProviderUserID != "" {
// slugForHandle returns "user" for empty input — prefer something
// less collision-prone for OAuth-no-email signups.
base = "user-" + safeSlugSuffix(prof.ProviderUserID)
}
emailVerified := prof.Email != "" // can only be verified if we have an email
var u *model.User
var err error
for attempt := 0; attempt < 8; attempt++ {
handle := base
if attempt > 0 {
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
}
u, err = s.users.CreateOAuth(ctx, optStr(prof.Email), displayName, handle, optStr(prof.AvatarURL), emailVerified)
if err == nil {
break
}
if errors.Is(err, repository.ErrEmailTaken) {
// Race: someone else just claimed this email. Fall back to step 2.
// Only reachable when prof.Email != "" (NULL emails never collide).
if u2, err2 := s.users.FindByEmail(ctx, prof.Email); err2 == nil {
_, _ = s.oauth.Link(ctx, u2.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
s.ensureEmailVerified(ctx, u2)
return s.issueSession(u2)
}
return nil, err
}
if !errors.Is(err, repository.ErrHandleTaken) {
return nil, err
}
}
if err != nil {
return nil, fmt.Errorf("create oauth user: %w", err)
}
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
return nil, err
}
// New public profile → refresh the sitemap so it's crawlable promptly.
s.reval.Trigger("sitemap")
return s.issueSession(u)
}
// safeSlugSuffix reduces an arbitrary provider id to a short ASCII tail
// suitable as part of a handle. Provider ids are typically all-digit
// (Facebook) or short ASCII (Google sub), so a trailing 8-char window is
// plenty and avoids handles getting silly-long.
func safeSlugSuffix(s string) string {
out := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return r
case r >= 'A' && r <= 'Z':
return r + ('a' - 'A')
default:
return -1
}
}, s)
if len(out) > 8 {
out = out[len(out)-8:]
}
if out == "" {
return randomHandleSuffix()
}
return out
}
// optStr returns nil for empty input, &s otherwise — used to populate
// nullable text columns from possibly-empty profile fields.
func optStr(s string) *string {
if s == "" {
return nil
}
return &s
}
// LinkedProviders returns the OAuth provider names already linked to the
// caller's account ("google", "facebook"). Used by the settings UI to flip
// the per-provider rows between "Connect" and "Connected".
func (s *AuthService) LinkedProviders(ctx context.Context, userID string) ([]string, error) {
if s.oauth == nil {
// OAuth repo not wired (no Postgres). No-op rather than erroring —
// settings just shows everything as un-connected.
return []string{}, nil
}
return s.oauth.ListProvidersForUser(ctx, userID)
}
// ChangePassword sets the caller's password, in two modes:
//
// 1. Account HAS a password — caller must supply the current one. We
// verify with bcrypt before replacing, defending against an attacker
// who got hold of the session cookie but doesn't know the password.
// 2. Account has NO password yet (OAuth-only signup) — caller can set
// one without proving anything beyond their existing session. There's
// no current password to verify against, and the session already
// grants them everything the new password would, so requiring an
// email round-trip here would only be friction.
//
// In both cases token_version is bumped (invalidating every JWT ever
// issued to this user) and a fresh session is re-issued so the calling
// device stays logged in while every other device is signed out.
func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) (*AuthResult, error) {
if len(newPassword) < 8 {
return nil, ErrWeakPassword
}
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, ErrUserNotFound
}
if u.PasswordHash != nil {
// Account has a password — current must match before we'll replace it.
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(currentPassword)); err != nil {
return nil, ErrInvalidCredentials
}
}
// If u.PasswordHash == nil we skip the verification: setting an initial
// password for an OAuth-only account doesn't require proving anything
// beyond the active session.
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
if err := s.users.UpdatePassword(ctx, userID, string(hash)); err != nil {
return nil, err
}
// Re-fetch to pick up the new token_version so the freshly-issued JWT
// has the right "tv" claim and the cookie set after this call keeps
// the user logged in on this device.
u2, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, err
}
return s.issueSession(u2)
}
// SetEmail fills in (or replaces) the caller's email address. Used by the
// "add your email" flow for OAuth users who signed up without one, and by
// any user who later wants to change their address.
//
// The address is stored as unverified — a verification link is sent to it
// immediately. Returns ErrEmailAlreadyTaken if another row already owns it.
func (s *AuthService) SetEmail(ctx context.Context, userID, email string) (*model.User, error) {
u, err := s.users.SetEmail(ctx, userID, email)
if err != nil {
if errors.Is(err, repository.ErrEmailTaken) {
return nil, ErrEmailAlreadyTaken
}
return nil, err
}
if err := s.sendVerificationEmail(ctx, u); err != nil {
// Verification email failure shouldn't block the set — the user can
// always trigger a resend.
s.logger.Warn("verification email failed after SetEmail",
zap.String("user", u.ID), zap.Error(err))
}
return u, nil
}
// ensureEmailVerified flips email_verified_at to now() on the given user
// if it's not already set. Called from every SignInWithOAuth branch so a
// successful OAuth round-trip immediately satisfies email verification
// for an existing-but-unverified account. Mutates u in place so callers
// see the updated timestamp without re-fetching.
func (s *AuthService) ensureEmailVerified(ctx context.Context, u *model.User) {
if u == nil || u.EmailVerifiedAt != nil {
return
}
if err := s.users.MarkEmailVerified(ctx, u.ID); err != nil {
s.logger.Warn("mark email verified after oauth", zap.String("user", u.ID), zap.Error(err))
return
}
now := time.Now()
u.EmailVerifiedAt = &now
}
// Me looks up a user by id and re-verifies the JWT's token_version against
// the stored value. Mismatch means the user has logged-out-everywhere or
// changed password since this JWT was issued → reject.
func (s *AuthService) Me(ctx context.Context, userID string, tokenVersion int) (*model.User, error) {
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return nil, ErrUserNotFound
}
if u.TokenVersion != tokenVersion {
return nil, auth.ErrInvalidToken
}
return u, nil
}
// RequestPasswordReset always returns nil to callers — we don't want to
// reveal whether the email is registered. If the email exists we mint a
// token and email a link; otherwise we silently no-op.
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
u, err := s.users.FindByEmail(ctx, email)
if err != nil {
if errors.Is(err, repository.ErrUserNotFound) {
return nil // do not reveal
}
return err
}
// Invalidate any previous reset tokens for this user.
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposePasswordReset)
raw, hash, err := newToken()
if err != nil {
return err
}
if _, err := s.tokens.Create(ctx, u.ID, model.PurposePasswordReset, hash, s.PasswordResetTTL); err != nil {
return err
}
// OAuth-only users may have no email on file — skip the send silently
// (the caller already cannot tell whether the email was registered, so
// "no email to send to" surfaces the same way).
if u.Email == nil || *u.Email == "" {
return nil
}
link := fmt.Sprintf("%s/reset-password?token=%s", s.frontend, raw)
body := passwordResetEmail(u.Name, link)
if err := s.mail.Send(*u.Email, "Reset your Prabhupāda Says password", body); err != nil {
s.logger.Warn("password reset email failed", zap.String("user", u.ID), zap.Error(err))
}
return nil
}
// ResetPassword consumes a reset token and sets a new password.
// On success the user's token_version is bumped, invalidating every
// outstanding JWT (so a stolen token can't be used after the legit owner
// changes their password).
func (s *AuthService) ResetPassword(ctx context.Context, rawToken, newPassword string) error {
if len(newPassword) < 8 {
return ErrWeakPassword
}
hash := hashToken(rawToken)
t, err := s.tokens.FindValid(ctx, hash, model.PurposePasswordReset)
if err != nil {
return ErrInvalidOrExpiredLink
}
bcryptHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if err := s.users.UpdatePassword(ctx, t.UserID, string(bcryptHash)); err != nil {
return err
}
if err := s.tokens.Consume(ctx, t.ID); err != nil {
s.logger.Warn("consume reset token", zap.String("token_id", t.ID), zap.Error(err))
}
return nil
}
// VerifyEmail consumes an email-verification token and flips the user's
// email_verified_at timestamp.
func (s *AuthService) VerifyEmail(ctx context.Context, rawToken string) error {
hash := hashToken(rawToken)
t, err := s.tokens.FindValid(ctx, hash, model.PurposeEmailVerification)
if err != nil {
return ErrInvalidOrExpiredLink
}
if err := s.users.MarkEmailVerified(ctx, t.UserID); err != nil {
return err
}
if err := s.tokens.Consume(ctx, t.ID); err != nil {
s.logger.Warn("consume verify token", zap.String("token_id", t.ID), zap.Error(err))
}
return nil
}
// ResendVerification re-fires the verification email for the currently-logged-in
// user. No-ops with no error if the user is already verified.
func (s *AuthService) ResendVerification(ctx context.Context, userID string) error {
u, err := s.users.FindByID(ctx, userID)
if err != nil {
return ErrUserNotFound
}
if u.EmailVerifiedAt != nil {
return ErrEmailAlreadyVerified
}
return s.sendVerificationEmail(ctx, u)
}
// --- internal helpers ---
func (s *AuthService) issueSession(u *model.User) (*AuthResult, error) {
tok, exp, err := s.jwt.Issue(u.ID, u.TokenVersion)
if err != nil {
return nil, err
}
return &AuthResult{User: u, Token: tok, Expires: exp}, nil
}
func (s *AuthService) sendVerificationEmail(ctx context.Context, u *model.User) error {
// Users with no email on file (OAuth signups where the provider didn't
// return one) have nothing to send to. The caller is expected to gate
// on this, but defensively no-op here too.
if u.Email == nil || *u.Email == "" {
return nil
}
_ = s.tokens.InvalidatePending(ctx, u.ID, model.PurposeEmailVerification)
raw, hash, err := newToken()
if err != nil {
return err
}
if _, err := s.tokens.Create(ctx, u.ID, model.PurposeEmailVerification, hash, s.EmailVerifyTTL); err != nil {
return err
}
link := fmt.Sprintf("%s/verify-email?token=%s", s.frontend, raw)
body := verifyEmailMessage(u.Name, link)
return s.mail.Send(*u.Email, "Verify your email — Prabhupāda Says", body)
}
// newToken returns (rawHex, sha256HexHash). The raw value goes in the
// emailed link; only the hash is stored in the DB.
func newToken() (raw, hash string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
raw = hex.EncodeToString(b)
hash = hashToken(raw)
return raw, hash, nil
}
// slugForHandle turns the local-part of an email into a kebab-case handle.
// Falls back to "user" for fully-stripped inputs so we always have a seed
// the retry loop can append a suffix to.
func slugForHandle(email string) string {
at := strings.IndexByte(email, '@')
local := email
if at >= 0 {
local = email[:at]
}
var b strings.Builder
b.Grow(len(local))
for _, r := range strings.ToLower(local) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '.', r == '_', r == '-':
b.WriteByte('-')
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
out = "user"
}
if len(out) > 32 {
out = out[:32]
}
return out
}
// randomHandleSuffix returns 5 hex characters (~20 bits of entropy) — plenty
// to disambiguate names that already exist in the user base.
func randomHandleSuffix() string {
b := make([]byte, 3)
_, _ = rand.Read(b)
return hex.EncodeToString(b)[:5]
}
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// --- email bodies (intentionally minimal HTML; auth mails should be plain) ---
func passwordResetEmail(name, link string) string {
return fmt.Sprintf(`<p>Hari Bol %s,</p>
<p>You (or someone using your email) asked to reset your password on
<strong>Prabhupāda Says</strong>. Click the link below to set a new one:</p>
<p><a href="%s">%s</a></p>
<p>This link expires in 1 hour. If you didn't request a reset, just ignore
this email — your password stays the same.</p>`, htmlEscape(name), link, link)
}
func verifyEmailMessage(name, link string) string {
return fmt.Sprintf(`<p>Hari Bol %s,</p>
<p>Welcome to <strong>Prabhupāda Says</strong>. Please confirm your email by
clicking the link below:</p>
<p><a href="%s">%s</a></p>
<p>This link expires in 24 hours. You can request a new one from the banner
shown at the top of the site after you log in.</p>`, htmlEscape(name), link, link)
}
func loginOTPEmail(name, otp string) string {
return fmt.Sprintf(`<p>Hari Bol %s,</p>
<p>Your account on <strong>Prabhupāda Says</strong> has been unverified for a
while, so we need a quick one-time code before signing you in:</p>
<p style="font-size:1.5rem;font-weight:600;letter-spacing:0.25em;margin:1.25rem 0;">%s</p>
<p>This code expires in 10 minutes. If you didn't try to sign in, you can
safely ignore this email.</p>`, htmlEscape(name), otp)
}
func htmlEscape(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
)
return r.Replace(s)
}
+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
}

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