43 Commits
Author SHA1 Message Date
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
125 changed files with 15553 additions and 344 deletions
+5
View File
@@ -24,6 +24,11 @@ R2_SECRET_ACCESS_KEY=
R2_BUCKET=
# Public base URL where bucket objects are served (custom domain or pub-XXX.r2.dev)
R2_PUBLIC_URL=
# Separate bucket for private media (DM attachments), read only via short-lived
# presigned GETs. It must have NO public access and no custom domain: R2 public
# access is per-bucket, so a prefix inside R2_BUCKET would still be world-readable.
# Empty disables private-media endpoints (they return 503).
R2_PRIVATE_BUCKET=
# SMTP (password-reset + email-verification + /feedback admin notifications)
SMTP_HOST=
+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)
}
+20 -1
View File
@@ -37,6 +37,13 @@ type Config struct {
R2Bucket string
R2PublicURL string // Public base URL for objects, e.g. https://cdn.example.com or the R2 public bucket URL
// R2PrivateBucket is a SEPARATE bucket with no public access, used for
// objects that must never be fetchable by URL alone (DM attachments).
// A prefix inside R2Bucket would not do: R2 public access is per-bucket,
// so anything in that bucket is world-readable. Empty disables the
// private-media endpoints (they return 503) — the BFF still boots.
R2PrivateBucket string
// SMTP — used for password-reset, email-verification, and
// /feedback admin-notification mails.
SMTPHost string
@@ -66,6 +73,15 @@ type Config struct {
// Frontend
FrontendURL string // for CORS + reset/verify links in emails
// AdminURL is the admin panel's origin. When set it's added to the CORS
// allow-list (the admin app is a separate origin from FrontendURL). Empty
// disables admin-origin CORS (only the main frontend is allowed).
AdminURL string
// RevalidateSecret authenticates BFF→frontend on-demand cache
// revalidation pings (POST /api/revalidate). Empty disables the pings.
RevalidateSecret string
// BFF public URL — the externally-reachable origin of this server. Used
// to build the OAuth redirect_uri the provider sees (must match the
// allow-list configured in the provider's console). In dev this is
@@ -127,6 +143,7 @@ func Load() (*Config, error) {
R2SecretAccessKey: getEnv("R2_SECRET_ACCESS_KEY", ""),
R2Bucket: getEnv("R2_BUCKET", ""),
R2PublicURL: getEnv("R2_PUBLIC_URL", ""),
R2PrivateBucket: getEnv("R2_PRIVATE_BUCKET", ""),
SMTPHost: getEnv("SMTP_HOST", ""),
SMTPPort: getEnvInt("SMTP_PORT", 587),
@@ -147,7 +164,9 @@ func Load() (*Config, error) {
CookieDomain: getEnv("COOKIE_DOMAIN", ""),
CookieSecure: getEnvBool("COOKIE_SECURE", false),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
FrontendURL: getEnv("FRONTEND_URL", "http://localhost:3000"),
AdminURL: getEnv("ADMIN_URL", ""),
RevalidateSecret: getEnv("REVALIDATE_SECRET", ""),
BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"),
+425
View File
@@ -0,0 +1,425 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in
// the router, so every method here can assume an admin caller.
type AdminHandler struct{ svc *service.AdminService }
func NewAdminHandler(svc *service.AdminService) *AdminHandler { return &AdminHandler{svc: svc} }
// pageParams pulls (q, limit, offset) from the query string with sane defaults.
func pageParams(r *http.Request) (q string, limit, offset int) {
q = strings.TrimSpace(r.URL.Query().Get("q"))
limit = 25
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 {
limit = v
}
offset = 0
if v, err := strconv.Atoi(r.URL.Query().Get("offset")); err == nil && v >= 0 {
offset = v
}
return q, limit, offset
}
// meta builds the pagination envelope metadata from a total + the request page.
func meta(total, limit, offset int) *response.Meta {
if limit <= 0 {
limit = 25
}
pages := (total + limit - 1) / limit
return &response.Meta{
Page: offset/limit + 1,
Limit: limit,
Total: int64(total),
TotalPages: pages,
}
}
// Stats — GET /admin/stats → system snapshot for the dashboard home.
func (h *AdminHandler) Stats(w http.ResponseWriter, r *http.Request) {
snap, err := h.svc.Stats(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load stats")
return
}
response.JSON(w, http.StatusOK, snap)
}
func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
q, limit, offset := pageParams(r)
items, total, err := h.svc.ListUsers(r.Context(), q, limit, offset)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not list users")
return
}
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
}
func (h *AdminHandler) ListPosts(w http.ResponseWriter, r *http.Request) {
q, limit, offset := pageParams(r)
items, total, err := h.svc.ListPosts(r.Context(), q, limit, offset)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not list posts")
return
}
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
}
func (h *AdminHandler) ListCollections(w http.ResponseWriter, r *http.Request) {
q, limit, offset := pageParams(r)
items, total, err := h.svc.ListCollections(r.Context(), q, limit, offset)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not list collections")
return
}
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
}
func (h *AdminHandler) ListFeedback(w http.ResponseWriter, r *http.Request) {
_, limit, offset := pageParams(r)
status := strings.TrimSpace(r.URL.Query().Get("status"))
items, total, err := h.svc.ListFeedback(r.Context(), status, limit, offset)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not list feedback")
return
}
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
}
func (h *AdminHandler) ListReports(w http.ResponseWriter, r *http.Request) {
q, limit, offset := pageParams(r)
reportType := strings.TrimSpace(r.URL.Query().Get("type"))
if reportType == "" {
reportType = "post"
}
if reportType != "post" && reportType != "comment" && reportType != "user" {
response.Error(w, http.StatusBadRequest, "type must be post, comment, or user")
return
}
items, total, err := h.svc.ListReports(r.Context(), reportType, q, limit, offset)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not list reports")
return
}
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
}
// NotifyUser — POST /admin/users/{id}/notify { "title": "...", "body": "..." }
func (h *AdminHandler) NotifyUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var in struct {
Title string `json:"title"`
Body string `json:"body"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
in.Title = strings.TrimSpace(in.Title)
in.Body = strings.TrimSpace(in.Body)
if in.Body == "" {
response.Error(w, http.StatusBadRequest, "message body is required")
return
}
if len(in.Title) > 120 || len(in.Body) > 4000 {
response.Error(w, http.StatusBadRequest, "message too long")
return
}
err := h.svc.NotifyUser(r.Context(), id, in.Title, in.Body)
if errors.Is(err, service.ErrAdminUserNotFound) {
response.Error(w, http.StatusNotFound, "user not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not send notification")
return
}
response.JSON(w, http.StatusOK, map[string]any{"sent": true})
}
// ReplyFeedback — POST /admin/feedback/{id}/reply { "message": "..." }
func (h *AdminHandler) ReplyFeedback(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var in struct {
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
in.Message = strings.TrimSpace(in.Message)
if in.Message == "" {
response.Error(w, http.StatusBadRequest, "reply message is required")
return
}
if len(in.Message) > 4000 {
response.Error(w, http.StatusBadRequest, "reply too long")
return
}
found, emailed, err := h.svc.ReplyFeedback(r.Context(), id, in.Message)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not save reply")
return
}
if !found {
response.Error(w, http.StatusNotFound, "feedback not found")
return
}
response.JSON(w, http.StatusOK, map[string]any{"replied": true, "emailed": emailed})
}
// AcknowledgeFeedback — POST /admin/feedback/{id}/acknowledge
func (h *AdminHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) {
err := h.svc.AcknowledgeFeedback(r.Context(), chi.URLParam(r, "id"))
// Already-acknowledged is a no-op success from the client's perspective.
if err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) {
response.Error(w, http.StatusInternalServerError, "could not acknowledge feedback")
return
}
response.JSON(w, http.StatusOK, map[string]any{"acknowledged": true})
}
// ResolveFeedback — POST /admin/feedback/{id}/resolve
func (h *AdminHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) {
err := h.svc.ResolveFeedback(r.Context(), chi.URLParam(r, "id"))
if err != nil && !errors.Is(err, service.ErrAlreadyResolved) {
response.Error(w, http.StatusInternalServerError, "could not resolve feedback")
return
}
response.JSON(w, http.StatusOK, map[string]any{"resolved": true})
}
// ResolveReport — POST /admin/reports/{type}/{id}/resolve
func (h *AdminHandler) ResolveReport(w http.ResponseWriter, r *http.Request) {
reportType := chi.URLParam(r, "type")
id := chi.URLParam(r, "id")
if reportType != "post" && reportType != "comment" && reportType != "user" {
response.Error(w, http.StatusBadRequest, "type must be post, comment, or user")
return
}
var in struct {
Note string `json:"note"`
}
// Body is optional/best-effort; an empty/missing note resolves with no note.
_ = json.NewDecoder(r.Body).Decode(&in)
resolved, err := h.svc.ResolveReport(r.Context(), reportType, id, strings.TrimSpace(in.Note))
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not resolve report")
return
}
// resolved=false means it was already resolved or unknown — still 200 so
// the action is idempotent from the client's perspective.
response.JSON(w, http.StatusOK, map[string]any{"resolved": resolved})
}
// ─── Moderation actions ──────────────────────────────────────────────────────
// HidePost — POST /admin/posts/{id}/hide
func (h *AdminHandler) HidePost(w http.ResponseWriter, r *http.Request) {
h.setPostHidden(w, r, true)
}
// UnhidePost — DELETE /admin/posts/{id}/hide
func (h *AdminHandler) UnhidePost(w http.ResponseWriter, r *http.Request) {
h.setPostHidden(w, r, false)
}
func (h *AdminHandler) setPostHidden(w http.ResponseWriter, r *http.Request, hidden bool) {
ok, err := h.svc.SetPostHidden(r.Context(), chi.URLParam(r, "id"), hidden)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not update post")
return
}
if !ok {
response.Error(w, http.StatusNotFound, "post not found")
return
}
response.JSON(w, http.StatusOK, map[string]any{"hidden": hidden})
}
// DeletePost — DELETE /admin/posts/{id} (soft-delete; restorable)
func (h *AdminHandler) DeletePost(w http.ResponseWriter, r *http.Request) {
ok, err := h.svc.DeletePost(r.Context(), chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not delete post")
return
}
if !ok {
response.Error(w, http.StatusNotFound, "post not found")
return
}
response.JSON(w, http.StatusOK, map[string]any{"deleted": true})
}
// RestorePost — POST /admin/posts/{id}/restore
func (h *AdminHandler) RestorePost(w http.ResponseWriter, r *http.Request) {
ok, err := h.svc.RestorePost(r.Context(), chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not restore post")
return
}
if !ok {
response.Error(w, http.StatusNotFound, "post not found")
return
}
response.JSON(w, http.StatusOK, map[string]any{"restored": true})
}
// DeleteComment — DELETE /admin/comments/{id} (soft-delete; restorable)
func (h *AdminHandler) DeleteComment(w http.ResponseWriter, r *http.Request) {
ok, err := h.svc.DeleteComment(r.Context(), chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not delete comment")
return
}
if !ok {
response.Error(w, http.StatusNotFound, "comment not found")
return
}
response.JSON(w, http.StatusOK, map[string]any{"deleted": true})
}
// RestoreComment — POST /admin/comments/{id}/restore
func (h *AdminHandler) RestoreComment(w http.ResponseWriter, r *http.Request) {
ok, err := h.svc.RestoreComment(r.Context(), chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not restore comment")
return
}
if !ok {
response.Error(w, http.StatusNotFound, "comment not found")
return
}
response.JSON(w, http.StatusOK, map[string]any{"restored": true})
}
// BanUser — POST /admin/users/{id}/ban
func (h *AdminHandler) BanUser(w http.ResponseWriter, r *http.Request) {
h.setUserBanned(w, r, true)
}
// UnbanUser — DELETE /admin/users/{id}/ban
func (h *AdminHandler) UnbanUser(w http.ResponseWriter, r *http.Request) {
h.setUserBanned(w, r, false)
}
func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) {
ok, err := h.svc.SetUserBanned(r.Context(), chi.URLParam(r, "id"), banned)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not update account")
return
}
if !ok {
response.Error(w, http.StatusNotFound, "user not found")
return
}
response.JSON(w, http.StatusOK, map[string]any{"banned": banned})
}
// SetUserLimits — POST /admin/users/{id}/limits with body
// { "bookmark_limit": 10|null, "public_collection_limit": …, "private_collection_limit": … }.
// Each field is optional: a null (or omitted) value resets that limit to the
// plan default; a number caps that resource for this user regardless of plan.
// ListDisciples — GET /admin/disciples?q=&limit= — options for the
// direct-disciple picker. Read-only; an empty q returns the first page
// alphabetically so the picker opens with content.
func (h *AdminHandler) ListDisciples(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := h.svc.SearchDisciples(r.Context(), r.URL.Query().Get("q"), limit)
if err != nil {
if errors.Is(err, service.ErrDisciplesUnavailable) {
response.Error(w, http.StatusServiceUnavailable, "disciple directory unavailable")
return
}
response.Error(w, http.StatusInternalServerError, "could not search disciples")
return
}
response.JSON(w, http.StatusOK, items)
}
// LinkDisciple — POST /admin/users/{id}/disciple {"disciple_id":"…"} — marks the
// account as one of Śrīla Prabhupāda's direct disciples. Re-posting with a
// different id moves the link.
func (h *AdminHandler) LinkDisciple(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var in struct {
DiscipleID string `json:"disciple_id"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if strings.TrimSpace(in.DiscipleID) == "" {
response.Error(w, http.StatusBadRequest, "disciple_id is required")
return
}
switch err := h.svc.LinkDisciple(r.Context(), id, in.DiscipleID); {
case err == nil:
response.JSON(w, http.StatusOK, map[string]any{"linked": true})
case errors.Is(err, service.ErrAdminUserNotFound):
response.Error(w, http.StatusNotFound, "user not found")
case errors.Is(err, service.ErrDiscipleNotFound):
response.Error(w, http.StatusNotFound, "disciple not found")
case errors.Is(err, repository.ErrDiscipleClaimed):
response.Error(w, http.StatusConflict, "that disciple is already linked to another user")
case errors.Is(err, service.ErrDisciplesUnavailable):
response.Error(w, http.StatusServiceUnavailable, "disciple directory unavailable")
default:
response.Error(w, http.StatusInternalServerError, "could not link disciple")
}
}
// UnlinkDisciple — DELETE /admin/users/{id}/disciple — removes the link.
func (h *AdminHandler) UnlinkDisciple(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
switch err := h.svc.UnlinkDisciple(r.Context(), id); {
case err == nil:
response.JSON(w, http.StatusOK, map[string]any{"linked": false})
case errors.Is(err, service.ErrAdminUserNotFound):
response.Error(w, http.StatusNotFound, "user not found")
default:
response.Error(w, http.StatusInternalServerError, "could not unlink disciple")
}
}
func (h *AdminHandler) SetUserLimits(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var in struct {
BookmarkLimit *int `json:"bookmark_limit"`
PublicCollectionLimit *int `json:"public_collection_limit"`
PrivateCollectionLimit *int `json:"private_collection_limit"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
const maxLimit = 100000
for _, v := range []*int{in.BookmarkLimit, in.PublicCollectionLimit, in.PrivateCollectionLimit} {
if v != nil && (*v < 0 || *v > maxLimit) {
response.Error(w, http.StatusBadRequest, "each limit must be between 0 and 100000")
return
}
}
ok, err := h.svc.SetUserLimits(r.Context(), id, in.BookmarkLimit, in.PublicCollectionLimit, in.PrivateCollectionLimit)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not update limits")
return
}
if !ok {
response.Error(w, http.StatusNotFound, "user not found")
return
}
response.JSON(w, http.StatusOK, map[string]any{"updated": true})
}
+50
View File
@@ -0,0 +1,50 @@
package handler
import (
"net/http"
"time"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// ListShortURLs — GET /admin/short-urls?q=&limit=&offset= — paginated list of
// shortened URLs, newest first.
func (h *AdminHandler) ListShortURLs(w http.ResponseWriter, r *http.Request) {
q, limit, offset := pageParams(r)
items, total, err := h.svc.ListShortURLs(r.Context(), q, limit, offset)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not list short urls")
return
}
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
}
// ShortURLStats — GET /admin/short-urls/stats?from=YYYY-MM-DD&to=YYYY-MM-DD —
// today/week/month/all-time counts, plus a custom range when from+to are given.
func (h *AdminHandler) ShortURLStats(w http.ResponseWriter, r *http.Request) {
from, to := parseDateRange(r)
stats, err := h.svc.ShortURLStats(r.Context(), from, to)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load short url stats")
return
}
response.JSON(w, http.StatusOK, stats)
}
// parseDateRange reads ?from=YYYY-MM-DD&to=YYYY-MM-DD (UTC). `to` is treated as
// inclusive, so the returned upper bound is the exclusive start of the next
// day. Returns (nil, nil) unless both dates are present and valid.
func parseDateRange(r *http.Request) (*time.Time, *time.Time) {
fs := r.URL.Query().Get("from")
ts := r.URL.Query().Get("to")
if fs == "" || ts == "" {
return nil, nil
}
from, err1 := time.ParseInLocation("2006-01-02", fs, time.UTC)
to, err2 := time.ParseInLocation("2006-01-02", ts, time.UTC)
if err1 != nil || err2 != nil {
return nil, nil
}
toExclusive := to.AddDate(0, 0, 1)
return &from, &toExclusive
}
+98
View File
@@ -0,0 +1,98 @@
package handler
import (
"encoding/json"
"net/http"
"time"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// maxAudioProgressItems bounds a single upsert payload. A normal save
// sends one item; the on-login sync sends the user's whole local set,
// which is realistically small. The cap just guards against abuse.
const maxAudioProgressItems = 2000
// AudioProgressHandler serves the audio player's per-file resume points.
// All routes are auth-required; the user comes from the request context
// populated by middleware.RequireAuth.
type AudioProgressHandler struct {
svc *service.AudioProgressService
}
func NewAudioProgressHandler(svc *service.AudioProgressService) *AudioProgressHandler {
return &AudioProgressHandler{svc: svc}
}
type audioProgressItems struct {
Items []model.AudioProgress `json:"items"`
}
// Get — GET /users/me/audio-progress. Returns every saved resume point so
// the client can hydrate its in-memory map on sign-in.
func (h *AudioProgressHandler) Get(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
items, err := h.svc.List(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load audio progress")
return
}
if items == nil {
items = []model.AudioProgress{}
}
response.JSON(w, http.StatusOK, audioProgressItems{Items: items})
}
// Put — PUT /users/me/audio-progress. Upserts a batch of resume points
// (one item for a normal save; N for the on-login sync). Newest-wins is
// enforced in SQL, so order/conflicts are safe.
func (h *AudioProgressHandler) Put(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var body audioProgressItems
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body (expect { items: [...] })")
return
}
if len(body.Items) > maxAudioProgressItems {
response.Error(w, http.StatusBadRequest, "too many items")
return
}
clean := make([]model.AudioProgress, 0, len(body.Items))
for _, it := range body.Items {
if it.Key == "" || it.Position < 0 {
continue // drop malformed entries rather than fail the batch
}
if it.UpdatedAt.IsZero() {
it.UpdatedAt = time.Now().UTC()
}
clean = append(clean, it)
}
if err := h.svc.Upsert(r.Context(), uid, clean); err != nil {
response.Error(w, http.StatusInternalServerError, "could not save audio progress")
return
}
response.NoContent(w)
}
// Delete — DELETE /users/me/audio-progress?key=<url-encoded key>. Removes a
// single resume point (used when a track plays to completion). The key is
// a URL, so it travels as a query param to avoid path-encoding issues.
func (h *AudioProgressHandler) Delete(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
key := r.URL.Query().Get("key")
if key == "" {
response.Error(w, http.StatusBadRequest, "missing key")
return
}
if err := h.svc.Delete(r.Context(), uid, key); err != nil {
response.Error(w, http.StatusInternalServerError, "could not delete audio progress")
return
}
response.NoContent(w)
}
+2
View File
@@ -375,6 +375,8 @@ func (h *AuthHandler) renderAuthErr(w http.ResponseWriter, err error) {
response.Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, service.ErrUserNotFound):
response.Error(w, http.StatusNotFound, err.Error())
case errors.Is(err, service.ErrAccountSuspended):
response.Error(w, http.StatusForbidden, err.Error())
default:
response.Error(w, http.StatusInternalServerError, "something went wrong")
}
+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)
}
}
+626
View File
@@ -0,0 +1,626 @@
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.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"`
}
// 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"`
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,
})
}
msg, err := h.svc.Send(r.Context(), userID, convID, service.SendRequest{
Body: req.Body,
ClientMsgID: req.ClientMsgID,
ReplyToID: req.ReplyToID,
PreviewURL: req.PreviewURL,
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 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})
}
// Receipts — GET /chat/conversations/{id}/receipts
func (h *ChatHandler) Receipts(w http.ResponseWriter, r *http.Request) {
rc, err := h.svc.Receipts(r.Context(), middleware.UserID(r.Context()), chi.URLParam(r, "id"))
if err != nil {
writeChatError(w, err)
return
}
response.JSON(w, http.StatusOK, rc)
}
func atoiOr(s string, fallback int) int {
if s == "" {
return fallback
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return fallback
}
return n
}
+156
View File
@@ -0,0 +1,156 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// CommentHandler serves the post-comment endpoints: listing top-level comments
// + a thread's replies (public), and create / like / pin / delete / report
// (auth-required).
type CommentHandler struct {
svc *service.CommentService
}
func NewCommentHandler(svc *service.CommentService) *CommentHandler {
return &CommentHandler{svc: svc}
}
type createCommentReq struct {
Body string `json:"body"`
// ParentID nil → top-level comment; otherwise the comment being replied
// to (the service flattens replies onto the thread root).
ParentID *string `json:"parent_id"`
}
type commentLikeResp struct {
LikesCount int `json:"likes_count"`
}
// List — GET /posts/{id}/comments?limit=&offset=. Top-level comments, pinned
// first then newest. Public (OptionalAuth populates viewer_has_liked).
func (h *CommentHandler) List(w http.ResponseWriter, r *http.Request) {
postID := chi.URLParam(r, "id")
viewerID := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
items, total, err := h.svc.List(r.Context(), postID, viewerID, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, items, paginationMeta(total, limit, offset))
}
// Replies — GET /comments/{id}/replies?limit=&offset=. A thread's replies,
// oldest first. Public (OptionalAuth).
func (h *CommentHandler) Replies(w http.ResponseWriter, r *http.Request) {
commentID := chi.URLParam(r, "id")
viewerID := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
items, total, err := h.svc.Replies(r.Context(), commentID, viewerID, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, items, paginationMeta(total, limit, offset))
}
// Create — POST /posts/{id}/comments. Body: {body, parent_id?}. RequireAuth.
func (h *CommentHandler) Create(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
postID := chi.URLParam(r, "id")
var req createCommentReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
// Treat an empty parent_id as a top-level comment, not a (missing) reply.
if req.ParentID != nil && *req.ParentID == "" {
req.ParentID = nil
}
c, err := h.svc.Add(r.Context(), uid, postID, req.ParentID, req.Body)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusCreated, c)
}
// Like — POST /comments/{id}/like. Idempotent. RequireAuth.
func (h *CommentHandler) Like(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
count, err := h.svc.Like(r.Context(), uid, id)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, commentLikeResp{LikesCount: count})
}
// Unlike — DELETE /comments/{id}/like. Idempotent. RequireAuth.
func (h *CommentHandler) Unlike(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
count, err := h.svc.Unlike(r.Context(), uid, id)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, commentLikeResp{LikesCount: count})
}
// Pin — POST /comments/{id}/pin. Post author only; top-level only. RequireAuth.
func (h *CommentHandler) Pin(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
if err := h.svc.Pin(r.Context(), uid, id); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// Unpin — DELETE /comments/{id}/pin. Post author only. RequireAuth.
func (h *CommentHandler) Unpin(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
if err := h.svc.Unpin(r.Context(), uid, id); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// Delete — DELETE /comments/{id}. Comment author or post author. RequireAuth.
// For a thread root this removes the whole thread.
func (h *CommentHandler) Delete(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
if err := h.svc.Delete(r.Context(), uid, id); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// Report — POST /comments/{id}/report. Body: {category, reason?}. RequireAuth.
func (h *CommentHandler) Report(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
id := chi.URLParam(r, "id")
var req reportPostReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid request body")
return
}
if err := h.svc.Report(r.Context(), uid, id, req.Category, req.Reason); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
+25 -2
View File
@@ -9,10 +9,33 @@ import (
type DiscipleHandler struct {
repo *repository.DiscipleRepo
// users (Postgres) resolves which disciples have a platform account
// linked by an admin. Separate store from the disciple records
// themselves, hence the second dependency.
users *repository.UserRepo
}
func NewDiscipleHandler(repo *repository.DiscipleRepo) *DiscipleHandler {
return &DiscipleHandler{repo: repo}
func NewDiscipleHandler(repo *repository.DiscipleRepo, users *repository.UserRepo) *DiscipleHandler {
return &DiscipleHandler{repo: repo, users: users}
}
// Linked — GET /disciples/linked — every disciple that has a platform account,
// as {disciple_id, handle, name, avatar_url}. Public and unpaged: only Śrīla
// Prabhupāda's own disciples are ever linked, so the set is tiny and the
// /disciples browser fetches it once to highlight the matching cards.
func (h *DiscipleHandler) Linked(w http.ResponseWriter, r *http.Request) {
if h.users == nil {
// Degraded boot without Postgres — an empty set just means "no
// highlights", which is a fine reading experience.
response.JSON(w, http.StatusOK, []repository.DiscipleLink{})
return
}
links, err := h.users.ListDiscipleLinks(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "failed to fetch linked disciples")
return
}
response.JSON(w, http.StatusOK, links)
}
func (h *DiscipleHandler) List(w http.ResponseWriter, r *http.Request) {
+18
View File
@@ -68,3 +68,21 @@ func (h *FeedbackHandler) Submit(w http.ResponseWriter, r *http.Request) {
}
response.JSON(w, http.StatusCreated, f)
}
// Mine returns the authenticated caller's own feedback submissions,
// newest first — each with its status (acknowledged / resolved) and any
// admin reply. Registered under RequireAuth, so UserID is always set;
// the guard is belt-and-suspenders.
func (h *FeedbackHandler) Mine(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if uid == "" {
response.Error(w, http.StatusUnauthorized, "authentication required")
return
}
items, err := h.svc.ListForUser(r.Context(), uid, 50)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load your feedback")
return
}
response.JSON(w, http.StatusOK, items)
}
+77
View File
@@ -1,6 +1,8 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
@@ -49,3 +51,78 @@ func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request
}
response.NoContent(w)
}
// GetPrefs — GET /notifications/prefs. The resolved Settings matrix:
// {"prefs": {"follows": {"in_app": true, "email": true, "push": true}, …}}.
func (h *NotificationHandler) GetPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
matrix, err := h.svc.PrefMatrix(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load notification preferences")
return
}
response.JSON(w, http.StatusOK, map[string]any{"prefs": matrix})
}
// UpdatePrefs — PUT /notifications/prefs { event, channel, enabled }. Writes
// one matrix cell; returns the full re-resolved matrix so the client can
// render authoritative state.
func (h *NotificationHandler) UpdatePrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req struct {
Event string `json:"event"`
Channel string `json:"channel"`
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetPref(r.Context(), uid, req.Event, req.Channel, req.Enabled); err != nil {
if errors.Is(err, service.ErrUnknownPref) {
response.Error(w, http.StatusBadRequest, "unknown event or channel")
return
}
response.Error(w, http.StatusInternalServerError, "could not update notification preferences")
return
}
matrix, err := h.svc.PrefMatrix(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load notification preferences")
return
}
response.JSON(w, http.StatusOK, map[string]any{"prefs": matrix})
}
type emailPrefsResp struct {
LikeEmails bool `json:"like_emails"`
}
// GetEmailPrefs — GET /notifications/email-prefs. The caller's email-notification
// preferences (currently just aggregated like emails).
func (h *NotificationHandler) GetEmailPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
enabled, err := h.svc.LikeEmailPref(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load email preferences")
return
}
response.JSON(w, http.StatusOK, emailPrefsResp{LikeEmails: enabled})
}
// UpdateEmailPrefs — PUT /notifications/email-prefs { like_emails }.
func (h *NotificationHandler) UpdateEmailPrefs(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req struct {
LikeEmails bool `json:"like_emails"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if err := h.svc.SetLikeEmailPref(r.Context(), uid, req.LikeEmails); err != nil {
response.Error(w, http.StatusInternalServerError, "could not update email preferences")
return
}
response.JSON(w, http.StatusOK, emailPrefsResp{LikeEmails: req.LikeEmails})
}
+29 -4
View File
@@ -1,6 +1,7 @@
package handler
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/url"
@@ -19,6 +20,11 @@ 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
@@ -46,6 +52,21 @@ func validatePushEndpoint(endpoint string) bool {
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.
@@ -98,8 +119,9 @@ func (h *PushHandler) Subscribe(w http.ResponseWriter, r *http.Request) {
response.Error(w, http.StatusBadRequest, "invalid push endpoint")
return
}
if len(req.Keys.P256dh) > maxKeyLen || len(req.Keys.Auth) > maxKeyLen {
response.Error(w, http.StatusBadRequest, "subscription keys too long")
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 {
@@ -115,18 +137,21 @@ type unsubscribeReq struct {
Endpoint string `json:"endpoint"`
}
// Unsubscribe — POST /push/unsubscribe. Removes a subscription by 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.DeleteByEndpoint(r.Context(), req.Endpoint); err != nil {
if err := h.repo.DeleteByEndpointForUser(r.Context(), uid, req.Endpoint); err != nil {
response.Error(w, http.StatusInternalServerError, "could not remove subscription")
return
}
+22 -9
View File
@@ -203,11 +203,20 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
prioritizeTokenPos := true
prefix, numTypos := fuzzinessKnobs(exact)
searches := make([]api.MultiSearchCollectionParameters, 0, len(collections))
// Merge-paginate: every sub-search fetches the first page*limit hits (page 1,
// a wide window) so the merged top page*limit is fully covered; we slice the
// requested page out after sorting below. Asking each collection for its OWN
// page N instead would miss globally-ranked hits and go blank past page 1
// (e.g. a sparse query whose few hits all sit in each collection's page 1).
window := page * limit
if window > 250 { // Typesense per_page ceiling
window = 250
}
for _, col := range collections {
queryBy := collectionQueryByFields[col]
weights := collectionQueryByWeights[col]
perPage := limit
pg := page
perPage := window
pg := 1
params := api.MultiSearchCollectionParameters{
Collection: col,
Q: stringPtr(query),
@@ -262,14 +271,18 @@ func (h *SearchHandler) multiSearch(ctx context.Context, collections []string, q
return allItems[i].score > allItems[j].score
})
// Paginate the merged results: since each sub-search already returned `limit`
// results for the given page, the merged set has at most 9*limit items.
// We take the top `limit` after sorting.
if len(allItems) > limit {
allItems = allItems[:limit]
// Slice the requested page out of the merged ranking. Collections hold
// disjoint documents, so totalFound (sum of per-collection founds) is the
// exact total and start past the end means the page is genuinely empty.
start := (page - 1) * limit
if start >= len(allItems) {
return []searchResultItem{}, totalFound
}
return allItems, totalFound
end := start + limit
if end > len(allItems) {
end = len(allItems)
}
return allItems[start:end], totalFound
}
// singleSearch searches a single collection.
+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})
}
+306 -26
View File
@@ -12,6 +12,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/middleware"
@@ -59,10 +60,11 @@ func (h *PostHandler) Report(w http.ResponseWriter, r *http.Request) {
}
type createPostReq struct {
Caption string `json:"caption"`
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
ImageURLs []string `json:"image_urls"`
Caption string `json:"caption"`
SourceURL string `json:"source_url"`
SourceTitle string `json:"source_title"`
ImageURLs []string `json:"image_urls"`
MentionUserIDs []string `json:"mention_user_ids"`
}
func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
@@ -79,7 +81,7 @@ func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
p, err := h.svc.Create(r.Context(), uid, req.Caption, req.SourceURL, req.SourceTitle, req.ImageURLs)
p, err := h.svc.Create(r.Context(), uid, req.Caption, req.SourceURL, req.SourceTitle, req.ImageURLs, req.MentionUserIDs)
if err != nil {
mapSocialErr(w, err)
return
@@ -87,6 +89,60 @@ func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusCreated, p)
}
// MentionSuggest — GET /posts/mention-suggest?q=&limit=&offset=. Prioritised
// user suggestions for the composer (following → followers → others).
func (h *PostHandler) MentionSuggest(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
q := strings.TrimSpace(r.URL.Query().Get("q"))
users, err := h.svc.SuggestMentions(r.Context(), uid, q, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
if users == nil {
users = []model.UserSummary{}
}
response.JSON(w, http.StatusOK, users)
}
// ListMentions — GET /posts/{id}/mentions. The users tagged on a post. Public.
func (h *PostHandler) ListMentions(w http.ResponseWriter, r *http.Request) {
users, err := h.svc.Mentions(r.Context(), chi.URLParam(r, "id"))
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, users)
}
// AddMention — POST /posts/{id}/mentions { user_id }. Author only.
func (h *PostHandler) AddMention(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
var req struct {
UserID string `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.UserID) == "" {
response.Error(w, http.StatusBadRequest, "missing user_id")
return
}
if err := h.svc.AddMention(r.Context(), uid, chi.URLParam(r, "id"), req.UserID); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// RemoveMention — DELETE /posts/{id}/mentions/{userID}. Author only.
func (h *PostHandler) RemoveMention(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.RemoveMention(r.Context(), uid, chi.URLParam(r, "id"), chi.URLParam(r, "userID")); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// Reshare materialises a reshare of the post identified by {id} as a
// new posts row owned by the caller. Idempotent — second call returns
// the same row (still 201). The body is the new (or existing) post so
@@ -179,9 +235,41 @@ func (h *PostHandler) Delete(w http.ResponseWriter, r *http.Request) {
response.NoContent(w)
}
// Hide — POST /posts/{id}/hide. Owner hides the post from timelines + public
// collections.
func (h *PostHandler) Hide(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.SetHidden(r.Context(), chi.URLParam(r, "id"), uid, true); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
// Unhide — DELETE /posts/{id}/hide. Restores the post to timelines.
func (h *PostHandler) Unhide(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
if err := h.svc.SetHidden(r.Context(), chi.URLParam(r, "id"), uid, false); err != nil {
mapSocialErr(w, err)
return
}
response.NoContent(w)
}
func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
// Keyset path (scroll feed): a `before` cursor pages older posts. Without a
// cursor we keep the offset+meta path the profile grid relies on.
if beforeAt, beforeID, ok := parseCursor(r); ok {
posts, err := h.svc.ListMineKeyset(r.Context(), uid, beforeAt, beforeID, limit)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, posts)
return
}
posts, total, err := h.svc.ListMine(r.Context(), uid, limit, offset)
if err != nil {
mapSocialErr(w, err)
@@ -190,6 +278,57 @@ func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) {
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
// ListHidden — GET /posts/hidden. The caller's hidden posts (auth required),
// for the /u/me "Hidden" tab where they can unhide them.
func (h *PostHandler) ListHidden(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
posts, total, err := h.svc.ListHidden(r.Context(), uid, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
// Feed — GET /feed?limit=&offset=. The global personalised home feed (auth
// required). Returns at most `limit` posts; the client infers "more" from a
// full page (no total count for an unbounded global feed).
func (h *PostHandler) Feed(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
limit, offset := parseLimitOffset(r)
posts, err := h.svc.Feed(r.Context(), uid, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, posts)
}
// FeedReset — POST /feed/reset. Clears the caller's home-feed seen-set so the
// next page starts fresh. Called on logout and on page unload (the realtime
// socket dropping) to evict the set promptly rather than waiting for its TTL.
func (h *PostHandler) FeedReset(w http.ResponseWriter, r *http.Request) {
h.svc.ResetFeed(r.Context(), middleware.UserID(r.Context()))
response.NoContent(w)
}
// FeedNewUsers — GET /feed/new-users. Up to 5 accounts that joined in the last
// 24h, for the feed's "new on the platform" discovery strip. Returns [] when
// there are none (the client hides the section).
func (h *PostHandler) FeedNewUsers(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
users, err := h.graph.NewUsers(r.Context(), uid, 5)
if err != nil {
mapSocialErr(w, err)
return
}
if users == nil {
users = []model.UserSummary{}
}
response.JSON(w, http.StatusOK, users)
}
type replaceImagesReq struct {
ImageURLs []string `json:"image_urls"`
}
@@ -489,10 +628,12 @@ type ProfileHandler struct {
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) *ProfileHandler {
return &ProfileHandler{users: users, posts: posts, collections: collections, graph: graph, likes: likes}
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) {
@@ -507,6 +648,16 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
response.Error(w, http.StatusInternalServerError, "could not load profile")
return
}
// Hidden-post count is meaningful only to the owner (drives the /u/me
// "Hidden" tab). Compute it just for self; 0 for everyone else.
hiddenCount := 0
if middleware.UserID(r.Context()) == u.ID {
hiddenCount, err = h.posts.CountHiddenByUser(r.Context(), u.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load profile")
return
}
}
cols, err := h.collections.ListPublicByUser(r.Context(), u.ID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load collections")
@@ -527,16 +678,49 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
"handle": u.Handle,
"name": u.Name,
"avatar_url": u.AvatarURL,
"bio": u.Bio,
"joined_at": u.CreatedAt,
"country": u.Country,
"post_count": count,
"hidden_count": hiddenCount,
"follower_count": followers,
"following_count": following,
"relationship": rel,
"public_collections": cols,
// True when an admin has linked this account to a record in the
// disciples directory — i.e. one of Śrīla Prabhupāda's own initiated
// disciples. Drives the badge on the profile header. Only the boolean
// is exposed; the underlying Mongo id stays internal.
"is_direct_disciple": u.DiscipleID != nil && *u.DiscipleID != "",
})
}
// gateByBlock resolves the profile owner by handle and returns ErrBlocked
// (→403) when a block exists in either direction between the signed-in
// viewer and the owner — so a blocked viewer can't read the owner's feed,
// tagged posts, or follower lists, even by hitting the API directly. It
// returns the resolved owner so callers needn't look it up again, and
// propagates ErrUserNotFound (→404) for an unknown handle. Anonymous
// viewers and owners viewing their own profile are never gated.
func (h *ProfileHandler) gateByBlock(r *http.Request, handle string) (*model.User, error) {
owner, err := h.users.FindByHandle(r.Context(), handle)
if err != nil {
return nil, err
}
viewerID := middleware.UserID(r.Context())
if viewerID == "" || viewerID == owner.ID {
return owner, nil
}
blocked, err := h.graph.IsBlockedBetween(r.Context(), viewerID, owner.ID)
if err != nil {
return nil, err
}
if blocked {
return nil, service.ErrBlocked
}
return owner, nil
}
// Feed returns posts for the public feed. Same payload as ListMine but
// looked up by handle so it's reachable anonymously. When an
// authenticated viewer hits this route (via OptionalAuth) each post
@@ -544,27 +728,82 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
// button. Anonymous viewers get `false` for every post at zero cost.
func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
limit, offset := parseLimitOffset(r)
posts, total, err := h.posts.ListByHandle(r.Context(), handle, limit, offset)
owner, err := h.gateByBlock(r, handle)
if err != nil {
mapSocialErr(w, err)
return
}
limit, offset := parseLimitOffset(r)
// gateByBlock already resolved the owner, so list by id rather than
// re-resolving the handle. Hidden posts never appear here (they live in
// the owner's separate Hidden tab).
//
// Keyset path (scroll feed): a `before` cursor pages older posts. Without a
// cursor we keep the offset+meta path the profile grid relies on.
if beforeAt, beforeID, ok := parseCursor(r); ok {
posts, err := h.posts.ListByUserKeyset(r.Context(), owner.ID, beforeAt, beforeID, limit)
if err != nil {
mapSocialErr(w, err)
return
}
if err := h.hydrateFeed(r, posts); err != nil {
mapSocialErr(w, err)
return
}
response.JSON(w, http.StatusOK, posts)
return
}
posts, total, err := h.posts.ListByUser(r.Context(), owner.ID, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
if err := h.hydrateFeed(r, posts); err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
// MentionsFeed — GET /u/{handle}/mentions?limit=&offset=. Posts that tag this
// user, newest first. Public (OptionalAuth) with viewer-state hydration.
func (h *ProfileHandler) MentionsFeed(w http.ResponseWriter, r *http.Request) {
handle := chi.URLParam(r, "handle")
if _, err := h.gateByBlock(r, handle); err != nil {
mapSocialErr(w, err)
return
}
limit, offset := parseLimitOffset(r)
posts, total, err := h.posts.ListMentioningByHandle(r.Context(), handle, limit, offset)
if err != nil {
mapSocialErr(w, err)
return
}
if err := h.hydrateFeed(r, posts); err != nil {
mapSocialErr(w, err)
return
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
}
// hydrateFeed fills viewer reshare state + like counts/state + mention counts
// across a page of posts. Shared by the profile feed and the mentions feed.
func (h *ProfileHandler) hydrateFeed(r *http.Request, posts []model.Post) error {
if len(posts) == 0 {
return nil
}
viewerID := middleware.UserID(r.Context())
if viewerID != "" && len(posts) > 0 {
if viewerID != "" {
ids := make([]string, 0, len(posts))
for i := range posts {
// Skip own posts — can't reshare yourself, and the swap
// already triggers on isOwner.
if posts[i].UserID != viewerID {
if posts[i].UserID != viewerID { // can't reshare your own post
ids = append(ids, posts[i].ID)
}
}
if len(ids) > 0 {
states, err := h.posts.ReshareStatesFor(r.Context(), viewerID, ids)
if err != nil {
mapSocialErr(w, err)
return
return err
}
for i := range posts {
if states[posts[i].ID] {
@@ -573,18 +812,17 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
}
}
}
// Like counts (effective) + viewer-liked state for every post in the page.
if len(posts) > 0 {
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 {
mapSocialErr(w, err)
return
}
ptrs := make([]*model.Post, len(posts))
for i := range posts {
ptrs[i] = &posts[i]
}
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
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:
@@ -600,6 +838,10 @@ type followListResp struct {
// 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)
@@ -613,6 +855,10 @@ func (h *ProfileHandler) Followers(w http.ResponseWriter, r *http.Request) {
// 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)
@@ -623,6 +869,21 @@ func (h *ProfileHandler) Following(w http.ResponseWriter, r *http.Request) {
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
// ---------------------------------------------------------------------------
@@ -631,10 +892,13 @@ func mapSocialErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, repository.ErrUserNotFound),
errors.Is(err, repository.ErrPostNotFound),
errors.Is(err, repository.ErrCommentNotFound),
errors.Is(err, repository.ErrBookmarkNotFound),
errors.Is(err, repository.ErrCollectionNotFound),
errors.Is(err, service.ErrNotFound):
response.Error(w, http.StatusNotFound, "not found")
case errors.Is(err, repository.ErrDuplicateComment):
response.Error(w, http.StatusConflict, "you've already posted this comment")
case errors.Is(err, service.ErrForbidden):
response.Error(w, http.StatusForbidden, "not allowed")
case errors.Is(err, service.ErrBlocked):
@@ -666,6 +930,22 @@ func parseLimitOffset(r *http.Request) (limit, offset int) {
return limit, offset
}
// parseCursor reads an optional keyset cursor for the scroll feed: `before` is
// the last seen post's created_at (RFC3339Nano) and `before_id` its id. ok is
// false when either is missing or unparseable (callers then page by offset).
func parseCursor(r *http.Request) (beforeAt time.Time, beforeID string, ok bool) {
at := strings.TrimSpace(r.URL.Query().Get("before"))
id := strings.TrimSpace(r.URL.Query().Get("before_id"))
if at == "" || id == "" {
return time.Time{}, "", false
}
t, err := time.Parse(time.RFC3339Nano, at)
if err != nil {
return time.Time{}, "", false
}
return t, id, true
}
func paginationMeta(total, limit, offset int) *response.Meta {
if limit == 0 {
limit = 20
+119
View File
@@ -0,0 +1,119 @@
package handler
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strings"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
"go.uber.org/zap"
)
// userIDFromNotifChannel extracts the user id from a per-user notifications
// channel, matching both the private name and the legacy public one.
//
// Because these channels are keyed by user, a `channel_vacated` webhook on one
// means that user's *last* connection dropped — i.e. they went offline across
// all their tabs — so we can evict their feed seen-set. Both names are matched
// for the duration of the private-channel migration: clients running a
// service-worker-cached bundle still sit on the legacy channel, and matching
// only one name would silently stop evicting for half the users.
func userIDFromNotifChannel(channel string) (string, bool) {
if uid, ok := strings.CutPrefix(channel, realtime.UserChannelPrefix); ok {
return uid, uid != ""
}
uid, ok := strings.CutPrefix(channel, realtime.LegacyUserChannelPrefix)
return uid, ok && uid != ""
}
// SoketiWebhookHandler receives Soketi (Pusher-compatible) webhooks. Soketi
// signs each delivery with the app secret (X-Pusher-Signature = hex
// HMAC-SHA256 of the raw body); we verify before acting. On channel_vacated
// for a per-user notifications channel we clear that user's home-feed seen-set
// — prompt cleanup of a true offline/disconnect, complementing the page-unload
// beacon and the set's own TTL.
type SoketiWebhookHandler struct {
appKey string
appSecret string
feed *repository.FeedRepo
logger *zap.Logger
}
func NewSoketiWebhookHandler(appKey, appSecret string, feed *repository.FeedRepo, logger *zap.Logger) *SoketiWebhookHandler {
return &SoketiWebhookHandler{appKey: appKey, appSecret: appSecret, feed: feed, logger: logger}
}
type soketiWebhookBody struct {
Events []struct {
Name string `json:"name"`
Channel string `json:"channel"`
} `json:"events"`
}
// Handle — POST /webhooks/soketi. Public route: the HMAC signature is the
// authentication, not a session cookie. Returns 200 on a valid signature so
// Soketi doesn't retry; 401 on a bad/absent signature.
func (h *SoketiWebhookHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Without a configured secret there's nothing to verify against — refuse
// rather than trust unsigned input.
if h.appSecret == "" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB cap
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if key := r.Header.Get("X-Pusher-Key"); h.appKey != "" && key != h.appKey {
h.logger.Warn("soketi webhook: app key mismatch", zap.String("got", key))
w.WriteHeader(http.StatusUnauthorized)
return
}
if !validSoketiSignature(h.appSecret, r.Header.Get("X-Pusher-Signature"), body) {
// Almost always a secret mismatch between Soketi and the BFF.
h.logger.Warn("soketi webhook: bad signature", zap.Int("body_bytes", len(body)))
w.WriteHeader(http.StatusUnauthorized)
return
}
var payload soketiWebhookBody
if err := json.Unmarshal(body, &payload); err != nil {
h.logger.Warn("soketi webhook: bad payload", zap.Error(err))
w.WriteHeader(http.StatusBadRequest)
return
}
ctx := r.Context()
cleared := 0
for _, e := range payload.Events {
// A per-user notifications channel emptying = that user is offline.
if e.Name != "channel_vacated" {
continue
}
if uid, ok := userIDFromNotifChannel(e.Channel); ok {
h.feed.Reset(ctx, uid)
cleared++
}
}
h.logger.Debug("soketi webhook: verified",
zap.Int("events", len(payload.Events)),
zap.Int("feed_sets_cleared", cleared))
w.WriteHeader(http.StatusOK)
}
// validSoketiSignature is a constant-time check of the hex HMAC-SHA256 of the
// raw body keyed by the app secret.
func validSoketiSignature(secret, provided string, body []byte) bool {
if provided == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(provided))
}
+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)
}
}
}
+16 -48
View File
@@ -1,14 +1,11 @@
package handler
import (
"errors"
"html/template"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/internal/service"
)
// StatsHandler renders a single-page HTML snapshot of the system —
@@ -17,17 +14,21 @@ import (
// public per current product call. If that ever needs to change, wrap
// the route in middleware in server.go.
//
// It also handles the "Mark resolved" form POST per feedback row, which
// stamps resolved_at (one-way) and fires the author thank-you email via
// the feedback service.
// It is READ-ONLY: feedback acknowledge/resolve actions live in the
// authenticated admin panel, never on this public page.
//
// PRIVACY: because the page is public, it must never render personal contact
// details. Anonymous submitters' email addresses are shown as "reply address
// on file" only — and FeedbackRepo.Recent doesn't even SELECT the column, so
// the address isn't in memory here to leak by accident. Anything added to this
// template needs the same scrutiny; the admin panel is the place for PII.
type StatsHandler struct {
stats *repository.StatsRepo
feedback *repository.FeedbackRepo
svc *service.FeedbackService
}
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo, svc *service.FeedbackService) *StatsHandler {
return &StatsHandler{stats: stats, feedback: feedback, svc: svc}
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo) *StatsHandler {
return &StatsHandler{stats: stats, feedback: feedback}
}
func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
@@ -55,33 +56,6 @@ func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
}
}
// AcknowledgeFeedback handles the per-row "Acknowledge" form POST. Marks
// the row acknowledged (one-way) and emails the author that we've seen
// it and started work, then 303-redirects back to /stats. Already-
// acknowledged or unknown ids redirect silently.
func (h *StatsHandler) AcknowledgeFeedback(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.svc.Acknowledge(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyAcknowledged) {
http.Error(w, "acknowledge failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/stats", http.StatusSeeOther)
}
// ResolveFeedback handles the per-row "Mark resolved" form POST. It marks
// the row resolved (one-way) and fires the author thank-you email, then
// 303-redirects back to /stats so a refresh doesn't re-submit. Already-
// resolved or unknown ids redirect silently — the desired end state is
// already true.
func (h *StatsHandler) ResolveFeedback(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.svc.Resolve(r.Context(), id); err != nil && !errors.Is(err, service.ErrAlreadyResolved) {
http.Error(w, "resolve failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/stats", http.StatusSeeOther)
}
var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{
"fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
}).Parse(statsHTML))
@@ -115,6 +89,7 @@ h2 { font-size: 1.05rem; margin-top: 2rem; margin-bottom: 0.5rem; border-bottom:
.fb .foot { display: flex; justify-content: flex-end; align-items: center; gap: 0.6rem; margin-top: 0.6rem }
.resolved-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #1e7a4a }
.acked-badge { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #9a6b2f }
.pending-badge { display: inline-flex; align-items: center; font-size: 0.78rem; font-weight: 600; color: #999 }
.resolve-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #fff; background: #1e7a4a; border: 0; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
.resolve-btn:hover { background: #196b40 }
.ack-btn { font: inherit; font-size: 0.8rem; font-weight: 600; color: #9a6b2f; background: #fff; border: 1px solid #d6b27a; border-radius: 6px; padding: 0.4rem 0.85rem; cursor: pointer }
@@ -158,8 +133,8 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
<div class="author">
{{if .AuthorHandle}}
@{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}}
{{else if .ContactEmail}}
anonymous · <code>{{.ContactEmail}}</code>
{{else if .HasContactEmail}}
anonymous · reply address on file
{{else}}
anonymous
{{end}}
@@ -168,17 +143,10 @@ code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size
{{if .ResolvedAt}}
{{if .AcknowledgedAt}}<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>{{end}}
<span class="resolved-badge">✓ Resolved {{fmtTime .ResolvedAt}}</span>
{{else if .AcknowledgedAt}}
<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>
{{else}}
{{if .AcknowledgedAt}}
<span class="acked-badge">✓ Acknowledged {{fmtTime .AcknowledgedAt}}</span>
{{else}}
<form method="post" action="/stats/feedback/{{.ID}}/acknowledge" onsubmit="return confirm('Acknowledge this feedback? The author will be emailed that we have seen it and started work. This cannot be undone.');">
<button type="submit" class="ack-btn">Acknowledge</button>
</form>
{{end}}
<form method="post" action="/stats/feedback/{{.ID}}/resolve" onsubmit="return confirm('Mark this feedback as resolved? This cannot be undone, and the author will be emailed.');">
<button type="submit" class="resolve-btn">Mark resolved</button>
</form>
<span class="pending-badge">Pending</span>
{{end}}
</div>
</div>
+46
View File
@@ -0,0 +1,46 @@
package handler
import (
"strings"
"testing"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
// Executes the public /stats template against representative rows and asserts
// no submitter email can appear in the output.
func TestStatsTemplateHidesEmails(t *testing.T) {
now := time.Now()
secret := "leak-me@example.com"
handle, name := "gopal", "Gopal Das"
rows := []model.Feedback{
// anonymous WITH a reply address on file
{ID: "1", Category: "bug", Message: "anon with email", CreatedAt: now,
HasContactEmail: true, ContactEmail: &secret},
// anonymous with no address at all
{ID: "2", Category: "general", Message: "anon no email", CreatedAt: now},
// authed submitter
{ID: "3", Category: "feature", Message: "authed", CreatedAt: now,
AuthorHandle: &handle, AuthorName: &name, AcknowledgedAt: &now, ResolvedAt: &now},
}
var sb strings.Builder
err := statsTpl.Execute(&sb, map[string]any{
"Stats": &repository.StatsSnapshot{Users: 1},
"Feedback": rows,
"GeneratedAt": "now",
})
if err != nil {
t.Fatalf("template execute failed: %v", err)
}
out := sb.String()
if strings.Contains(out, secret) || strings.Contains(out, "@example.com") {
t.Errorf("submitter email leaked into public /stats output")
}
for _, want := range []string{"reply address on file", "@gopal", "anon no email"} {
if !strings.Contains(out, want) {
t.Errorf("expected %q in output", want)
}
}
}
+24
View File
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/prabhupadasays/spsays-bff/internal/repository"
@@ -70,6 +71,29 @@ func (h *TranscriptionHandler) GetByType(w http.ResponseWriter, r *http.Request)
response.JSON(w, http.StatusOK, docs)
}
func (h *TranscriptionHandler) GetBooks(w http.ResponseWriter, r *http.Request) {
docs, err := h.repo.GetBooks(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "failed to fetch books")
return
}
response.JSON(w, http.StatusOK, docs)
}
func (h *TranscriptionHandler) GetByBook(w http.ResponseWriter, r *http.Request) {
code := strings.ToUpper(chi.URLParam(r, "book"))
if h.repo.BookTitle(code) == "" {
response.Error(w, http.StatusNotFound, "unknown book")
return
}
docs, err := h.repo.GetByBook(r.Context(), code)
if err != nil {
response.Error(w, http.StatusInternalServerError, "failed to fetch transcriptions")
return
}
response.JSON(w, http.StatusOK, docs)
}
func (h *TranscriptionHandler) GetYears(w http.ResponseWriter, r *http.Request) {
docs, err := h.repo.GetYears(r.Context())
if err != nil {
+27 -10
View File
@@ -30,6 +30,7 @@ type updateProfileReq struct {
Name *string `json:"name"`
AvatarURL *string `json:"avatar_url"`
Handle *string `json:"handle"`
Bio *string `json:"bio"`
Country *string `json:"country"`
// Devotional profile (user-editable).
Sampradaya *string `json:"sampradaya"`
@@ -57,6 +58,7 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
Name: req.Name,
AvatarURL: req.AvatarURL,
Handle: req.Handle,
Bio: req.Bio,
Country: req.Country,
Sampradaya: req.Sampradaya,
Gender: req.Gender,
@@ -94,21 +96,31 @@ type userSearchItem struct {
// the query carries an "@" prefix.
func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
response.JSON(w, http.StatusOK, map[string]any{
"query": "",
"results": []userSearchItem{},
})
return
}
limit := 20
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
limit = n
}
}
if q == "" {
response.JSON(w, http.StatusOK, map[string]any{
"query": "",
"results": []userSearchItem{},
"total": 0,
"page": 1,
"limit": limit,
"total_pages": 0,
})
return
}
page := 1
if v := r.URL.Query().Get("page"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 1 {
page = n
}
}
users, err := h.svc.Search(r.Context(), q, limit)
users, total, err := h.svc.Search(r.Context(), q, limit, (page-1)*limit)
if err != nil {
response.Error(w, http.StatusInternalServerError, "search failed")
return
@@ -122,9 +134,14 @@ func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) {
AvatarURL: u.AvatarURL,
})
}
totalPages := (total + limit - 1) / limit
response.JSON(w, http.StatusOK, map[string]any{
"query": q,
"results": items,
"query": q,
"results": items,
"total": total,
"page": page,
"limit": limit,
"total_pages": totalPages,
})
}
+28
View File
@@ -0,0 +1,28 @@
package handler
import (
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/service"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// VaniHandler serves the public "Vāṇī of the Day" pick for the home page.
type VaniHandler struct {
svc *service.VaniService
}
func NewVaniHandler(svc *service.VaniService) *VaniHandler {
return &VaniHandler{svc: svc}
}
// Today — GET /vani/today. Public; the same pick is returned to everyone for
// the current UTC day. Responds with `data: null` if there's no content.
func (h *VaniHandler) Today(w http.ResponseWriter, r *http.Request) {
payload, err := h.svc.Today(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load vāṇī of the day")
return
}
response.JSON(w, http.StatusOK, payload)
}
+34
View File
@@ -0,0 +1,34 @@
package middleware
import (
"net/http"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/response"
)
// RequireAdmin gates a route to users whose role is "admin". It MUST run after
// RequireAuth (it reads the authenticated user id from context, then looks up
// the row to check the role). Non-admins get 403; this keeps the whole
// /api/v1/admin/* surface invisible to ordinary accounts.
func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid := UserID(r.Context())
if uid == "" {
response.Error(w, http.StatusUnauthorized, "not authenticated")
return
}
u, err := users.FindByID(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "auth check failed")
return
}
if u.Role != "admin" {
response.Error(w, http.StatusForbidden, "admin access required")
return
}
next.ServeHTTP(w, r)
})
}
}
+13
View File
@@ -53,6 +53,12 @@ func RequireAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Hand
response.Error(w, http.StatusUnauthorized, "session expired — please log in again")
return
}
// Suspended accounts are locked out of every protected route, so a
// ban takes effect immediately for active sessions too.
if u.BannedAt != nil {
response.Error(w, http.StatusForbidden, "this account has been suspended")
return
}
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
@@ -94,6 +100,13 @@ func OptionalAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Han
}
}
// WithUserID returns a context carrying userID, as RequireAuth would set it.
// The exported counterpart to UserID: it lets handlers be exercised in tests
// without standing up a JWT issuer and a user repository.
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userIDKey, userID)
}
// UserID returns the authenticated user's id from the request context.
// Returns "" if RequireAuth has not run.
func UserID(ctx context.Context) string {
+47
View File
@@ -0,0 +1,47 @@
package middleware
import (
"net"
"net/http"
"strings"
)
// RealIP rewrites r.RemoteAddr to the genuine client IP for a deployment where
// a SINGLE trusted reverse proxy is the edge and nothing sits in front of it
// (here: Dokploy's Traefik — DNS points straight at it).
//
// Such an edge APPENDS the TCP peer it actually saw to the right of
// X-Forwarded-For, so the LAST entry is the real client and can't be spoofed:
// a client may only prepend left-hand entries, which we ignore. This is the
// opposite of chi's RealIP, which trusts the left-most X-Forwarded-For entry
// (and X-Real-IP) and is therefore spoofable behind an appending proxy —
// yielding the wrong GeoIP country and a forgeable rate-limit key.
//
// Downstream consumers (geoip.ClientIP, the rate limiter, the request logger)
// all read r.RemoteAddr, so fixing it here fixes them all. When no forwarded
// header is present (e.g. a direct hit on the container), RemoteAddr is left
// untouched.
func RealIP(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ip := clientIPFromEdge(r); ip != "" {
r.RemoteAddr = ip
}
next.ServeHTTP(w, r)
})
}
func clientIPFromEdge(r *http.Request) string {
// Right-most X-Forwarded-For entry = the IP the trusted edge observed.
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
last := strings.TrimSpace(parts[len(parts)-1])
if net.ParseIP(last) != nil {
return last
}
}
// Fallback: the edge also overwrites X-Real-IP from the connection.
if xr := strings.TrimSpace(r.Header.Get("X-Real-Ip")); net.ParseIP(xr) != nil {
return xr
}
return ""
}
+129
View File
@@ -0,0 +1,129 @@
package model
import "time"
// Admin DTOs — read-only projections served only to admins via /api/v1/admin/*.
// They deliberately differ from the public-facing models: they may expose
// fields (email, role, reporter identity) that the normal API hides.
// AdminUser is a row in the admin users table.
type AdminUser struct {
ID string `json:"id"`
Email *string `json:"email"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
Role string `json:"role"`
Plan string `json:"plan"`
EmailVerifiedAt *time.Time `json:"email_verified_at"`
BannedAt *time.Time `json:"banned_at"`
PostCount int `json:"post_count"`
CreatedAt time.Time `json:"created_at"`
// Connected auth methods. HasPassword means email+password login is set up;
// Providers lists linked OAuth providers (e.g. "google", "facebook").
HasPassword bool `json:"has_password"`
Providers []string `json:"providers"`
// Effective save limits in force for this user (plan default merged with any
// admin override); -1 means unlimited. The admin UI pre-fills the "Edit
// limits" modal with these.
BookmarkLimit int `json:"bookmark_limit"`
PublicCollectionLimit int `json:"public_collection_limit"`
PrivateCollectionLimit int `json:"private_collection_limit"`
// Direct-disciple link. DiscipleID is the raw Mongo ObjectID stored on the
// users row; DiscipleName is hydrated from Mongo by the admin service so the
// panel can show who the account is linked to (nil when unlinked, or when
// the id no longer resolves to a disciple document).
DiscipleID *string `json:"disciple_id"`
DiscipleName *string `json:"disciple_name"`
}
// AdminDisciple is one option in the admin panel's disciple picker — enough to
// disambiguate similarly-named disciples, plus the handle of the account that
// already claims it (nil when free to link).
type AdminDisciple struct {
ID string `json:"id"`
InitiatedName string `json:"initiated_name"`
Suffix string `json:"suffix"`
InitiationDate *time.Time `json:"initiation_date"`
InitiationPlace *string `json:"initiation_place"`
// LinkedHandle is the @handle of the user already linked to this disciple,
// so the picker can grey the row out instead of failing on submit.
LinkedHandle *string `json:"linked_handle"`
}
// AdminPost is a row in the admin posts table.
type AdminPost struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Caption string `json:"caption"`
SourceTitle string `json:"source_title"`
SourceURL string `json:"source_url"`
IsHidden bool `json:"is_hidden"`
DeletedAt *time.Time `json:"deleted_at"`
CreatedAt time.Time `json:"created_at"`
AuthorName string `json:"author_name"`
AuthorHandle string `json:"author_handle"`
LikesCount int `json:"likes_count"`
CommentsCount int `json:"comments_count"`
}
// AdminCollection is a row in the admin collections table.
type AdminCollection struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Description *string `json:"description"`
IsPublic bool `json:"is_public"`
ItemCount int `json:"item_count"`
CreatedAt time.Time `json:"created_at"`
OwnerName string `json:"owner_name"`
OwnerHandle string `json:"owner_handle"`
}
// AdminFeedback is a row in the admin feedback table (includes the reply).
type AdminFeedback struct {
ID string `json:"id"`
Category string `json:"category"`
Message string `json:"message"`
ContactEmail *string `json:"contact_email"`
CreatedAt time.Time `json:"created_at"`
AcknowledgedAt *time.Time `json:"acknowledged_at"`
ResolvedAt *time.Time `json:"resolved_at"`
RepliedAt *time.Time `json:"replied_at"`
ReplyMessage *string `json:"reply_message"`
AuthorHandle *string `json:"author_handle"`
AuthorName *string `json:"author_name"`
AuthorEmail *string `json:"author_email"`
}
// AdminReport is a unified projection over the three report tables
// (post_reports, comment_reports, user_reports). Type says which one.
type AdminReport struct {
ID string `json:"id"`
Type string `json:"type"` // "post" | "comment" | "user"
Category string `json:"category"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
ResolvedAt *time.Time `json:"resolved_at"`
ReporterHandle *string `json:"reporter_handle"`
ReporterName *string `json:"reporter_name"`
// ResolutionNote is the admin's note left when resolving (nil while open).
ResolutionNote *string `json:"resolution_note"`
// TargetRemoved is true when the reported content is currently removed —
// a soft-deleted post/comment, or a banned account — so the UI can offer
// "Undo" instead of "Delete"/"Ban".
TargetRemoved bool `json:"target_removed"`
// Target of the report. TargetID is the post/comment/user id; TargetText
// is the post caption, comment body, or reported user's handle so the
// admin sees what was flagged without a second fetch. TargetAuthorHandle
// is the handle of the content's author (nil for user reports).
TargetID string `json:"target_id"`
TargetText *string `json:"target_text"`
TargetAuthorHandle *string `json:"target_author_handle"`
}
+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"`
}
+245
View File
@@ -0,0 +1,245 @@
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"
)
// 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"`
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"`
}
// 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"`
}
+15
View File
@@ -31,6 +31,21 @@ type Feedback struct {
// resolve is a no-op.
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
// ReplyMessage is the admin's written reply to the submitter, sent
// (and stored) from the admin panel. NULL until an admin replies.
// RepliedAt stamps when it was sent. Surfaced to the submitter on the
// "My feedback" page as the team's response.
ReplyMessage *string `json:"reply_message,omitempty"`
RepliedAt *time.Time `json:"replied_at,omitempty"`
// HasContactEmail reports *whether* an anonymous submitter left a reply-to
// address, without carrying the address itself. Populated only by
// Recent(), whose sole consumer is the PUBLIC /stats page — that page has
// to distinguish "anonymous, but we can reply" from "anonymous dead end"
// while never handling the email. Admins see the real address in the
// admin panel, which is auth-gated.
HasContactEmail bool `json:"-"`
AuthorHandle *string `json:"author_handle,omitempty"`
AuthorName *string `json:"author_name,omitempty"`
AuthorEmail *string `json:"-"` // server-side only; never serialised
+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)
}
+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
}
+90 -7
View File
@@ -20,6 +20,11 @@ type Post struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// IsHidden — owner has hidden this post from timelines (their profile as
// seen by others, the home feed) and from public collections. It still
// shows to the owner on their own profile and in private collections.
IsHidden bool `json:"is_hidden"`
// Author info — populated by repo joins for feed listings.
AuthorName string `json:"author_name,omitempty"`
AuthorHandle string `json:"author_handle,omitempty"`
@@ -29,10 +34,10 @@ type Post struct {
// is a reshare; the four `Original*` fields carry the canonical
// post's author info so the frontend can render a
// "Reshared from @handle" link without an extra round-trip.
ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"`
OriginalAuthorName *string `json:"original_author_name,omitempty"`
OriginalAuthorHandle *string `json:"original_author_handle,omitempty"`
OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"`
ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"`
OriginalAuthorName *string `json:"original_author_name,omitempty"`
OriginalAuthorHandle *string `json:"original_author_handle,omitempty"`
OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"`
// ViewerHasReshared is true when the calling user has reshared the
// canonical version of this post. Hydrated by the service layer
@@ -46,6 +51,61 @@ type Post struct {
// 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.
@@ -86,12 +146,17 @@ type CollectionRecord struct {
// PublicProfile is what /u/{handle} returns — the parts of a User safe to
// expose to anyone, plus aggregate counts and the user's posts.
type PublicProfile struct {
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
// Bio is plain text; `@handle` mentions are linkified at render time.
Bio *string `json:"bio"`
JoinedAt time.Time `json:"joined_at"`
PostCount int `json:"post_count"`
BookmarkCount int `json:"-"` // never serialized — private
// IsDirectDisciple marks an account an admin has linked to the disciples
// directory — one of Śrīla Prabhupāda's own initiated disciples.
IsDirectDisciple bool `json:"is_direct_disciple"`
}
// RelationshipState is the viewer's relationship to a target profile,
@@ -118,6 +183,15 @@ type RelationshipState struct {
RequestsYou bool `json:"requests_you"`
Blocked bool `json:"blocked"`
BlockedBy bool `json:"blocked_by"`
// CanMessage reports whether the viewer may START a conversation with this
// profile, given the target's "who can message you" setting and any block.
// It lets the UI decide whether to offer a Message button rather than
// offering one that fails on tap.
CanMessage bool `json:"can_message"`
// MessageRestriction names the target's rule when CanMessage is false, so
// the UI can explain instead of silently hiding the button. Empty when
// messaging is allowed, or "blocked" when a block is the reason.
MessageRestriction string `json:"message_restriction,omitempty"`
}
// Follow is one row of the follows table — a directed, request-based
@@ -184,6 +258,15 @@ type Notification struct {
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.
+41 -4
View File
@@ -15,6 +15,9 @@ import (
// 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"`
@@ -39,6 +42,23 @@ type User struct {
// nil until known.
Country *string `json:"country"`
// DiscipleID links this account to a row in the Mongo `disciples`
// collection (ObjectID hex) when the account belongs to one of Śrīla
// Prabhupāda's own initiated disciples. Admin-set only — never
// self-serve — and unique across users, so one disciple maps to at most
// one account. nil for everybody else.
//
// Tagged `json:"-"`: the raw cross-store id is an internal detail. The
// public profile exposes only the derived `is_direct_disciple` flag.
DiscipleID *string `json:"-"`
// Bio is a short self-introduction (max 200 characters, enforced in the
// service by rune count) shown on the profile header. Stored as PLAIN
// TEXT — `@handle` mentions are kept verbatim and linkified by the
// frontend at render time, so it must never be injected as HTML.
// nil when unset.
Bio *string `json:"bio"`
// --- Devotional profile (user-editable) ---
// Gender is a free-but-constrained string ("male"|"female"|"other"),
// nil when unspecified.
@@ -68,19 +88,36 @@ type User struct {
TokenVersion int `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// BannedAt suspends the account when set: RequireAuth rejects the session
// and LogIn refuses to issue a new one. nil = in good standing.
BannedAt *time.Time `json:"banned_at,omitempty"`
// Per-user save-limit overrides (admin-settable; nil = use the plan
// default). Never serialized raw — surfaced only via the derived `limits`
// object in MarshalJSON so the app can render/enforce the right caps.
BookmarkLimit *int `json:"-"`
PublicCollectionLimit *int `json:"-"`
PrivateCollectionLimit *int `json:"-"`
}
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
// "Change password" vs "Set a password" for OAuth-only accounts, without
// the password hash itself ever leaving the server.
// the password hash itself ever leaving the server. `is_direct_disciple` is
// likewise derived — the raw cross-store DiscipleID stays server-side while
// the frontend gets the one bit it needs to render the badge.
func (u User) MarshalJSON() ([]byte, error) {
type alias User
return json.Marshal(&struct {
alias
HasPassword bool `json:"has_password"`
HasPassword bool `json:"has_password"`
IsDirectDisciple bool `json:"is_direct_disciple"`
Limits UserLimits `json:"limits"`
}{
alias: alias(u),
HasPassword: u.PasswordHash != nil,
alias: alias(u),
HasPassword: u.PasswordHash != nil,
IsDirectDisciple: u.DiscipleID != nil && *u.DiscipleID != "",
Limits: u.EffectiveLimits(),
})
}
+25
View File
@@ -0,0 +1,25 @@
package model
import "time"
// VaniPayload is the prebuilt display blob for the "Vāṇī of the Day" CTA. It's
// owned by the frontend's rendering needs and stored verbatim as JSONB, so the
// home page can render without re-reading Mongo.
type VaniPayload struct {
Kind string `json:"kind"` // "verse" | "prose" | "transcript"
Label string `json:"label"` // "Verse" | "Chapter" | "Lecture" | "Conversation"
Title string `json:"title"` // reference / chapter title / lecture name
Subtitle string `json:"subtitle"` // short excerpt or date · place
Href string `json:"href"` // in-app path to open the item
BookTitle string `json:"book_title,omitempty"` // e.g. "Bhagavad-gītā As It Is"
Date string `json:"date,omitempty"` // transcript date, for the "Lecture • DATE" line
}
// VaniPick is one persisted day's choice. RefID is `<kind>:<mongo _id hex>` and
// is what the 90-day no-repeat rule dedupes on.
type VaniPick struct {
Day time.Time
Kind string
RefID string
Payload VaniPayload
}
+473
View File
@@ -0,0 +1,473 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// AdminRepo holds the read-mostly queries that back the admin panel. Every
// list method returns its page plus the unfiltered total (via COUNT(*) OVER())
// so the UI can paginate without a second round-trip. All queries are new —
// they read existing tables but don't alter any existing repo behaviour.
type AdminRepo struct{ pg *Postgres }
func NewAdminRepo(pg *Postgres) *AdminRepo { return &AdminRepo{pg: pg} }
// clampLimit normalises pagination so a bad client can't ask for everything.
func clampLimit(limit int) int {
if limit <= 0 {
return 25
}
if limit > 100 {
return 100
}
return limit
}
// ListUsers returns a page of users filtered by an optional query (matched
// against name, handle, or email), newest first, with the total count.
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
limit = clampLimit(limit)
rows, err := r.pg.Pool.Query(ctx, `
SELECT u.id, u.email, u.handle, u.name, u.avatar_url, u.role, u.plan,
u.email_verified_at, u.banned_at,
(SELECT COUNT(*) FROM posts p WHERE p.user_id = u.id AND p.deleted_at IS NULL) AS post_count,
u.created_at,
(u.password_hash IS NOT NULL) AS has_password,
(SELECT COALESCE(array_agg(oa.provider ORDER BY oa.provider), ARRAY[]::text[])
FROM oauth_accounts oa WHERE oa.user_id = u.id) AS providers,
u.bookmark_limit, u.public_collection_limit, u.private_collection_limit,
u.disciple_id,
COUNT(*) OVER() AS total
FROM users u
WHERE ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.handle ILIKE '%'||$1||'%'
OR COALESCE(u.email, '') ILIKE '%'||$1||'%')
ORDER BY u.created_at DESC
LIMIT $2 OFFSET $3`, q, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("admin list users: %w", err)
}
defer rows.Close()
out := []model.AdminUser{}
total := 0
for rows.Next() {
var u model.AdminUser
var bookmarkLimit, publicCollLimit, privateCollLimit *int
if err := rows.Scan(&u.ID, &u.Email, &u.Handle, &u.Name, &u.AvatarURL,
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.PostCount, &u.CreatedAt,
&u.HasPassword, &u.Providers,
&bookmarkLimit, &publicCollLimit, &privateCollLimit,
&u.DiscipleID, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin user: %w", err)
}
// Resolve plan default + overrides so the panel shows what's in force.
lim := model.ResolveLimits(u.Plan, bookmarkLimit, publicCollLimit, privateCollLimit)
u.BookmarkLimit = lim.Bookmarks
u.PublicCollectionLimit = lim.PublicCollections
u.PrivateCollectionLimit = lim.PrivateCollections
out = append(out, u)
}
return out, total, rows.Err()
}
// ErrDiscipleClaimed is returned by LinkDisciple when another account already
// holds the requested disciple — the partial unique index on disciple_id makes
// the one-to-one rule a database guarantee rather than a check-then-write race.
var ErrDiscipleClaimed = errors.New("disciple already linked to another user")
// LinkDisciple points a user at a disciple record in Mongo (ObjectID hex).
// Re-linking simply overwrites any previous value, so "change the link" needs
// no separate unlink step. Returns ErrDiscipleClaimed when a different account
// already claims that disciple.
//
// `previous` is the link the row held *before* this call (nil when it had
// none), which is what lets the caller send the welcome email only on the
// first link — correcting a mis-link shouldn't welcome the devotee twice. The
// FROM subquery snapshots the old value in the same statement, so there's no
// read-then-write race.
func (r *AdminRepo) LinkDisciple(ctx context.Context, userID, discipleID string) (found bool, previous *string, err error) {
err = r.pg.Pool.QueryRow(ctx, `
UPDATE users u
SET disciple_id = $2,
updated_at = now()
FROM (SELECT id, disciple_id AS old FROM users WHERE id = $1) prev
WHERE u.id = prev.id
RETURNING prev.old`, userID, discipleID).Scan(&previous)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil, nil
}
if err != nil {
if IsUniqueViolation(err) {
return false, nil, ErrDiscipleClaimed
}
return false, nil, fmt.Errorf("admin link disciple: %w", err)
}
return true, previous, nil
}
// ClaimDiscipleAnnouncement records that this account's arrival has been
// announced to the community, returning true only for the caller that actually
// inserted the row.
//
// This is the whole idempotency guard for the broadcast: announcing writes a
// notification to every user, so it must fire at most once per devotee. Doing
// it as INSERT … ON CONFLICT DO NOTHING makes the check atomic — a
// double-clicked button, a retried request, or two admins acting at once still
// yields exactly one announcement.
func (r *AdminRepo) ClaimDiscipleAnnouncement(ctx context.Context, userID, discipleID string) (bool, error) {
tag, err := r.pg.Pool.Exec(ctx, `
INSERT INTO disciple_announcements (user_id, disciple_id)
VALUES ($1, $2)
ON CONFLICT (user_id) DO NOTHING`, userID, discipleID)
if err != nil {
return false, fmt.Errorf("claim disciple announcement: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// UnlinkDisciple clears a user's disciple link. Idempotent: returns true as
// long as the user exists, whether or not a link was present.
func (r *AdminRepo) UnlinkDisciple(ctx context.Context, userID string) (bool, error) {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE users
SET disciple_id = NULL,
updated_at = now()
WHERE id = $1`, userID)
if err != nil {
return false, fmt.Errorf("admin unlink disciple: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// SetUserLimits writes the per-user save-limit overrides (nil clears one back
// to the plan default). Returns false if the id doesn't exist.
func (r *AdminRepo) SetUserLimits(ctx context.Context, userID string, bookmark, publicCollection, privateCollection *int) (bool, error) {
tag, err := r.pg.Pool.Exec(ctx, `
UPDATE users
SET bookmark_limit = $2,
public_collection_limit = $3,
private_collection_limit = $4,
updated_at = now()
WHERE id = $1`, userID, bookmark, publicCollection, privateCollection)
if err != nil {
return false, fmt.Errorf("admin set user limits: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// UserExists reports whether a user id is real (used before sending a
// notification to it).
func (r *AdminRepo) UserExists(ctx context.Context, id string) (bool, error) {
var exists bool
err := r.pg.Pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, id).Scan(&exists)
if err != nil {
return false, fmt.Errorf("admin user exists: %w", err)
}
return exists, nil
}
// ListPosts returns a page of posts filtered by an optional query (caption,
// source title, or author handle), newest first, with the total count.
func (r *AdminRepo) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) {
limit = clampLimit(limit)
rows, err := r.pg.Pool.Query(ctx, `
SELECT p.id, p.user_id, p.caption, p.source_title, p.source_url, p.is_hidden, p.deleted_at, p.created_at,
u.name, u.handle, p.likes_count,
(SELECT COUNT(*) FROM post_comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL) AS comments_count,
COUNT(*) OVER() AS total
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE ($1 = '' OR p.caption ILIKE '%'||$1||'%' OR p.source_title ILIKE '%'||$1||'%'
OR u.handle ILIKE '%'||$1||'%')
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3`, q, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("admin list posts: %w", err)
}
defer rows.Close()
out := []model.AdminPost{}
total := 0
for rows.Next() {
var p model.AdminPost
if err := rows.Scan(&p.ID, &p.UserID, &p.Caption, &p.SourceTitle, &p.SourceURL,
&p.IsHidden, &p.DeletedAt, &p.CreatedAt, &p.AuthorName, &p.AuthorHandle,
&p.LikesCount, &p.CommentsCount, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin post: %w", err)
}
out = append(out, p)
}
return out, total, rows.Err()
}
// ListCollections returns a page of collections filtered by an optional query
// (title, description, or owner handle), newest first, with the total count.
func (r *AdminRepo) ListCollections(ctx context.Context, q string, limit, offset int) ([]model.AdminCollection, int, error) {
limit = clampLimit(limit)
rows, err := r.pg.Pool.Query(ctx, `
SELECT c.id, c.user_id, c.title, c.description, c.is_public, c.created_at,
(SELECT COUNT(*) FROM collection_items ci WHERE ci.collection_id = c.id) AS item_count,
u.name, u.handle,
COUNT(*) OVER() AS total
FROM collections c
JOIN users u ON u.id = c.user_id
WHERE ($1 = '' OR c.title ILIKE '%'||$1||'%' OR COALESCE(c.description, '') ILIKE '%'||$1||'%'
OR u.handle ILIKE '%'||$1||'%')
ORDER BY c.created_at DESC
LIMIT $2 OFFSET $3`, q, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("admin list collections: %w", err)
}
defer rows.Close()
out := []model.AdminCollection{}
total := 0
for rows.Next() {
var c model.AdminCollection
if err := rows.Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.IsPublic,
&c.CreatedAt, &c.ItemCount, &c.OwnerName, &c.OwnerHandle, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin collection: %w", err)
}
out = append(out, c)
}
return out, total, rows.Err()
}
// ListFeedback returns a page of feedback. status is "all" | "open" |
// "resolved". Newest first, with the total count for the chosen filter.
func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offset int) ([]model.AdminFeedback, int, error) {
limit = clampLimit(limit)
if status != "open" && status != "resolved" {
status = "all"
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT f.id, f.category, f.message, f.contact_email, f.created_at,
f.acknowledged_at, f.resolved_at, f.replied_at, f.reply_message,
u.handle, u.name, u.email,
COUNT(*) OVER() AS total
FROM feedback f
LEFT JOIN users u ON u.id = f.user_id
WHERE ($1 = 'all'
OR ($1 = 'open' AND f.resolved_at IS NULL)
OR ($1 = 'resolved' AND f.resolved_at IS NOT NULL))
ORDER BY f.created_at DESC
LIMIT $2 OFFSET $3`, status, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("admin list feedback: %w", err)
}
defer rows.Close()
out := []model.AdminFeedback{}
total := 0
for rows.Next() {
var f model.AdminFeedback
if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
}
out = append(out, f)
}
return out, total, rows.Err()
}
// SaveReply stores an admin reply on a feedback row and stamps replied_at. It
// does NOT change resolved_at — replying and resolving are independent actions
// (resolve via ResolveFeedback). It returns the recipient's email (the
// submitter's account email, else the anonymous contact_email — may be nil if
// neither is known) and name so the caller can send the reply email. ok is
// false when the id doesn't exist.
func (r *AdminRepo) SaveReply(ctx context.Context, id, message string) (email *string, name string, ok bool, err error) {
err = r.pg.Pool.QueryRow(ctx, `
UPDATE feedback f
SET reply_message = $2,
replied_at = now()
WHERE f.id = $1
RETURNING COALESCE((SELECT email FROM users WHERE id = f.user_id), f.contact_email),
COALESCE((SELECT name FROM users WHERE id = f.user_id), '')`,
id, message,
).Scan(&email, &name)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", false, nil
}
if err != nil {
return nil, "", false, fmt.Errorf("admin save feedback reply: %w", err)
}
return email, name, true, nil
}
// ListReports returns a page of reports of one type ("post" | "comment" |
// "user"), unresolved first then newest, filtered by an optional query, with
// the total count.
func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit, offset int) ([]model.AdminReport, int, error) {
limit = clampLimit(limit)
var sql string
switch reportType {
case "post":
sql = `
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
ru.handle, ru.name,
r.post_id, p.caption, pu.handle,
r.resolution_note, COALESCE(p.deleted_at IS NOT NULL, false),
COUNT(*) OVER() AS total
FROM post_reports r
LEFT JOIN users ru ON ru.id = r.reporter_id
LEFT JOIN posts p ON p.id = r.post_id
LEFT JOIN users pu ON pu.id = p.user_id
WHERE ($1 = '' OR COALESCE(p.caption, '') ILIKE '%'||$1||'%'
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%'
OR COALESCE(pu.handle, '') ILIKE '%'||$1||'%')
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
LIMIT $2 OFFSET $3`
case "comment":
sql = `
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
ru.handle, ru.name,
r.comment_id, c.body, cu.handle,
r.resolution_note, COALESCE(c.deleted_at IS NOT NULL, false),
COUNT(*) OVER() AS total
FROM comment_reports r
LEFT JOIN users ru ON ru.id = r.reporter_id
LEFT JOIN post_comments c ON c.id = r.comment_id
LEFT JOIN users cu ON cu.id = c.user_id
WHERE ($1 = '' OR COALESCE(c.body, '') ILIKE '%'||$1||'%'
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%'
OR COALESCE(cu.handle, '') ILIKE '%'||$1||'%')
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
LIMIT $2 OFFSET $3`
case "user":
sql = `
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
ru.handle, ru.name,
r.reported_id, tu.handle, tu.handle,
r.resolution_note, COALESCE(tu.banned_at IS NOT NULL, false),
COUNT(*) OVER() AS total
FROM user_reports r
LEFT JOIN users ru ON ru.id = r.reporter_id
LEFT JOIN users tu ON tu.id = r.reported_id
WHERE ($1 = '' OR COALESCE(tu.handle, '') ILIKE '%'||$1||'%'
OR COALESCE(tu.name, '') ILIKE '%'||$1||'%'
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%')
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
LIMIT $2 OFFSET $3`
default:
return nil, 0, fmt.Errorf("unknown report type %q", reportType)
}
rows, err := r.pg.Pool.Query(ctx, sql, q, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("admin list %s reports: %w", reportType, err)
}
defer rows.Close()
out := []model.AdminReport{}
total := 0
for rows.Next() {
rep := model.AdminReport{Type: reportType}
if err := rows.Scan(&rep.ID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt,
&rep.ReporterHandle, &rep.ReporterName,
&rep.TargetID, &rep.TargetText, &rep.TargetAuthorHandle,
&rep.ResolutionNote, &rep.TargetRemoved, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin report: %w", err)
}
out = append(out, rep)
}
return out, total, rows.Err()
}
// ResolveReport stamps resolved_at=now() and stores the admin's note on a
// still-open report of the given type. Idempotent and one-way (the WHERE
// guard). Returns true when this call is the one that resolved it.
func (r *AdminRepo) ResolveReport(ctx context.Context, reportType, id, note string) (bool, error) {
var table string
switch reportType {
case "post":
table = "post_reports"
case "comment":
table = "comment_reports"
case "user":
table = "user_reports"
default:
return false, fmt.Errorf("unknown report type %q", reportType)
}
// table is from a fixed whitelist above — safe to interpolate.
tag, err := r.pg.Pool.Exec(ctx,
fmt.Sprintf(`UPDATE %s SET resolved_at = now(), resolution_note = $2 WHERE id = $1 AND resolved_at IS NULL`, table),
id, note,
)
if err != nil {
return false, fmt.Errorf("admin resolve %s report: %w", reportType, err)
}
return tag.RowsAffected() == 1, nil
}
// ─── Moderation actions ──────────────────────────────────────────────────────
// SetPostHidden hides/unhides any post (admin override — not owner-scoped).
// Returns false if the id doesn't exist.
func (r *AdminRepo) SetPostHidden(ctx context.Context, postID string, hidden bool) (bool, error) {
tag, err := r.pg.Pool.Exec(ctx,
`UPDATE posts SET is_hidden = $2, updated_at = now() WHERE id = $1`, postID, hidden)
if err != nil {
return false, fmt.Errorf("admin set post hidden: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// SetPostDeleted soft-deletes or restores a post by stamping/clearing
// deleted_at. A soft-deleted post is filtered out of every read path (feeds,
// profile, hidden tab, collections, detail) but kept in the table so it can be
// restored. Returns false if the id doesn't exist.
func (r *AdminRepo) SetPostDeleted(ctx context.Context, postID string, deleted bool) (bool, error) {
var sql string
if deleted {
sql = `UPDATE posts SET deleted_at = now(), updated_at = now() WHERE id = $1`
} else {
sql = `UPDATE posts SET deleted_at = NULL, updated_at = now() WHERE id = $1`
}
tag, err := r.pg.Pool.Exec(ctx, sql, postID)
if err != nil {
return false, fmt.Errorf("admin set post deleted: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// SetCommentDeleted soft-deletes or restores a comment (same semantics as
// SetPostDeleted). Returns false if the id doesn't exist.
func (r *AdminRepo) SetCommentDeleted(ctx context.Context, commentID string, deleted bool) (bool, error) {
var sql string
if deleted {
sql = `UPDATE post_comments SET deleted_at = now(), updated_at = now() WHERE id = $1`
} else {
sql = `UPDATE post_comments SET deleted_at = NULL, updated_at = now() WHERE id = $1`
}
tag, err := r.pg.Pool.Exec(ctx, sql, commentID)
if err != nil {
return false, fmt.Errorf("admin set comment deleted: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// SetUserBanned suspends/un-suspends an account. Banning also bumps
// token_version, which invalidates the user's existing JWTs so the lock-out is
// immediate (RequireAuth additionally rejects banned users). Returns false if
// the id doesn't exist.
func (r *AdminRepo) SetUserBanned(ctx context.Context, userID string, banned bool) (bool, error) {
var sql string
if banned {
sql = `UPDATE users SET banned_at = now(), token_version = token_version + 1, updated_at = now() WHERE id = $1`
} else {
sql = `UPDATE users SET banned_at = NULL, updated_at = now() WHERE id = $1`
}
tag, err := r.pg.Pool.Exec(ctx, sql, userID)
if err != nil {
return false, fmt.Errorf("admin set user banned: %w", err)
}
return tag.RowsAffected() == 1, nil
}
@@ -0,0 +1,88 @@
package repository
import (
"context"
"fmt"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// ListShortURLs returns a page of shortened URLs filtered by an optional query
// (matched against code or target URL), newest first, with the total count.
func (r *AdminRepo) ListShortURLs(ctx context.Context, q string, limit, offset int) ([]model.AdminShortURL, int, error) {
limit = clampLimit(limit)
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, code, long_url, content_type, click_count, human_click_count,
disabled, created_at,
COUNT(*) OVER() AS total
FROM shortened_urls
WHERE ($1 = '' OR code ILIKE '%'||$1||'%' OR long_url ILIKE '%'||$1||'%')
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`, q, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("admin list short urls: %w", err)
}
defer rows.Close()
out := []model.AdminShortURL{}
total := 0
for rows.Next() {
var s model.AdminShortURL
if err := rows.Scan(&s.ID, &s.Code, &s.LongURL, &s.ContentType,
&s.ClickCount, &s.HumanClickCount, &s.Disabled, &s.CreatedAt, &total); err != nil {
return nil, 0, fmt.Errorf("scan admin short url: %w", err)
}
out = append(out, s)
}
return out, total, rows.Err()
}
// ShortURLStats computes created + click counts for the three fixed windows
// (thresholds passed in), the all-time totals, and — when from/to is given —
// a custom range (from inclusive, to exclusive). "Created" comes from
// shortened_urls.created_at; "clicks" from the shortened_url_clicks log.
func (r *AdminRepo) ShortURLStats(
ctx context.Context,
todayStart, weekStart, monthStart time.Time,
from, to *time.Time,
) (*model.ShortURLStats, error) {
var s model.ShortURLStats
if err := r.pg.Pool.QueryRow(ctx, `
SELECT COUNT(*),
COUNT(*) FILTER (WHERE created_at >= $1),
COUNT(*) FILTER (WHERE created_at >= $2),
COUNT(*) FILTER (WHERE created_at >= $3)
FROM shortened_urls`, todayStart, weekStart, monthStart).
Scan(&s.TotalURLs, &s.Today.Created, &s.Week.Created, &s.Month.Created); err != nil {
return nil, fmt.Errorf("short url created stats: %w", err)
}
if err := r.pg.Pool.QueryRow(ctx, `
SELECT COUNT(*),
COUNT(*) FILTER (WHERE clicked_at >= $1),
COUNT(*) FILTER (WHERE clicked_at >= $2),
COUNT(*) FILTER (WHERE clicked_at >= $3)
FROM shortened_url_clicks`, todayStart, weekStart, monthStart).
Scan(&s.TotalClicks, &s.Today.Clicks, &s.Week.Clicks, &s.Month.Clicks); err != nil {
return nil, fmt.Errorf("short url click stats: %w", err)
}
if from != nil && to != nil {
var rng model.ShortURLRangeStat
if err := r.pg.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM shortened_urls WHERE created_at >= $1 AND created_at < $2`,
*from, *to).Scan(&rng.Created); err != nil {
return nil, fmt.Errorf("short url range created: %w", err)
}
if err := r.pg.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM shortened_url_clicks WHERE clicked_at >= $1 AND clicked_at < $2`,
*from, *to).Scan(&rng.Clicks); err != nil {
return nil, fmt.Errorf("short url range clicks: %w", err)
}
s.Range = &rng
}
return &s, nil
}
@@ -0,0 +1,87 @@
package repository
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// AudioProgressRepo wraps Postgres access for audio_progress — the audio
// player's per-file resume points. One row per (user, audio_key).
type AudioProgressRepo struct{ pg *Postgres }
func NewAudioProgressRepo(pg *Postgres) *AudioProgressRepo {
return &AudioProgressRepo{pg: pg}
}
// ListByUser returns all of the user's saved resume points, newest first.
// Used to hydrate the player's in-memory map on sign-in.
func (r *AudioProgressRepo) ListByUser(ctx context.Context, userID string) ([]model.AudioProgress, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT audio_key, position_seconds, duration_seconds, updated_at
FROM audio_progress
WHERE user_id = $1
ORDER BY updated_at DESC`,
userID,
)
if err != nil {
return nil, fmt.Errorf("list audio progress: %w", err)
}
defer rows.Close()
var out []model.AudioProgress
for rows.Next() {
var p model.AudioProgress
if err := rows.Scan(&p.Key, &p.Position, &p.Duration, &p.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan audio progress: %w", err)
}
out = append(out, p)
}
return out, rows.Err()
}
// Upsert writes a batch of resume points. Each row is inserted or updated
// only when the incoming updated_at is at least as new as the stored one
// (newest-wins), so concurrent tabs/devices and the on-login sync of
// anonymous IndexedDB entries never clobber a fresher position.
func (r *AudioProgressRepo) Upsert(ctx context.Context, userID string, items []model.AudioProgress) error {
if len(items) == 0 {
return nil
}
batch := &pgx.Batch{}
for _, it := range items {
batch.Queue(`
INSERT INTO audio_progress
(user_id, audio_key, position_seconds, duration_seconds, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, audio_key) DO UPDATE
SET position_seconds = EXCLUDED.position_seconds,
duration_seconds = EXCLUDED.duration_seconds,
updated_at = EXCLUDED.updated_at
WHERE EXCLUDED.updated_at >= audio_progress.updated_at`,
userID, it.Key, it.Position, it.Duration, it.UpdatedAt)
}
br := r.pg.Pool.SendBatch(ctx, batch)
defer br.Close()
for range items {
if _, err := br.Exec(); err != nil {
return fmt.Errorf("upsert audio progress: %w", err)
}
}
return nil
}
// Delete removes a single resume point (called when a track plays to
// completion so it restarts from the beginning next time).
func (r *AudioProgressRepo) Delete(ctx context.Context, userID, key string) error {
_, err := r.pg.Pool.Exec(ctx,
`DELETE FROM audio_progress WHERE user_id = $1 AND audio_key = $2`,
userID, key,
)
if err != nil {
return fmt.Errorf("delete audio progress: %w", err)
}
return nil
}
File diff suppressed because it is too large Load Diff
+434
View File
@@ -0,0 +1,434 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/prabhupadasays/spsays-bff/internal/model"
"go.uber.org/zap"
)
// These exercise real SQL against a real Postgres — the LATERAL joins, the
// FOR UPDATE sequencing and the partial indexes are the parts most likely to be
// wrong, and none of them can be checked without a database. Skipped when no
// POSTGRES_URI is configured, so `go test ./...` still passes anywhere.
func testRepo(t *testing.T) (*ChatRepo, *Postgres) {
t.Helper()
uri := os.Getenv("POSTGRES_URI")
if uri == "" {
_ = godotenv.Load(filepath.Join("..", "..", ".env"))
uri = os.Getenv("POSTGRES_URI")
}
if uri == "" {
t.Skip("POSTGRES_URI not set — skipping chat repository integration tests")
}
pg, err := NewPostgres(context.Background(), uri, zap.NewNop())
if err != nil {
t.Skipf("postgres unreachable: %v", err)
}
t.Cleanup(pg.Close)
return NewChatRepo(pg), pg
}
// makeUser inserts a throwaway user and removes it (and anything cascading
// from it) when the test finishes.
func makeUser(t *testing.T, pg *Postgres, label string) string {
t.Helper()
ctx := context.Background()
id := uuid.NewString()
suffix := id[:8]
_, err := pg.Pool.Exec(ctx, `
INSERT INTO users (id, email, password_hash, name, handle)
VALUES ($1, $2, 'x', $3, $4)`,
id, fmt.Sprintf("chattest-%s@example.invalid", suffix), "Chat Test "+label, "chattest_"+suffix)
if err != nil {
t.Fatalf("create test user: %v", err)
}
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, id)
})
return id
}
// dropConversation removes a thread when the test ends; messages, participants
// and attachments cascade with it.
func dropConversation(t *testing.T, pg *Postgres, id string) {
t.Cleanup(func() {
_, _ = pg.Pool.Exec(context.Background(), `DELETE FROM conversations WHERE id = $1`, id)
})
}
func send(t *testing.T, r *ChatRepo, convID, sender, body string) *model.Message {
t.Helper()
m, _, err := r.Append(context.Background(), AppendParams{
ConversationID: convID, SenderID: &sender, Kind: model.MessageText, Body: body,
})
if err != nil {
t.Fatalf("append %q: %v", body, err)
}
return m
}
func TestEnsureDM_IsIdempotentFromEitherSide(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
first, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatalf("EnsureDM: %v", err)
}
dropConversation(t, pg, first.ID)
// Same pair, opposite argument order — must resolve to the same thread,
// which is the whole point of the sorted dm_key.
second, err := r.EnsureDM(ctx, b, a)
if err != nil {
t.Fatalf("EnsureDM reversed: %v", err)
}
if second.ID != first.ID {
t.Errorf("reversed EnsureDM made a second thread: %s vs %s", second.ID, first.ID)
}
for _, u := range []string{a, b} {
if _, err := r.Participant(ctx, first.ID, u); err != nil {
t.Errorf("user %s is not a participant: %v", u, err)
}
}
}
func TestParticipant_RejectsOutsiders(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b, outsider := makeUser(t, pg, "A"), makeUser(t, pg, "B"), makeUser(t, pg, "C")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
if _, err := r.Participant(ctx, c.ID, outsider); !errors.Is(err, ErrNotParticipant) {
t.Errorf("outsider got %v, want ErrNotParticipant", err)
}
}
func TestAppend_AssignsGaplessSequence(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
for i := int64(1); i <= 3; i++ {
m := send(t, r, c.ID, a, fmt.Sprintf("msg %d", i))
if m.Seq != i {
t.Errorf("message %d got seq %d", i, m.Seq)
}
}
var lastSeq int64
if err := pg.Pool.QueryRow(ctx, `SELECT last_seq FROM conversations WHERE id=$1`, c.ID).Scan(&lastSeq); err != nil {
t.Fatal(err)
}
if lastSeq != 3 {
t.Errorf("conversation.last_seq = %d, want 3", lastSeq)
}
}
// A retried send must return the original message and must NOT burn a sequence
// number — a gap would break the client's catch-up cursor.
func TestAppend_DeduplicatesRetries(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
clientID := uuid.NewString()
p := AppendParams{ConversationID: c.ID, SenderID: &a, Kind: model.MessageText,
Body: "only once", ClientMsgID: &clientID}
first, created, err := r.Append(ctx, p)
if err != nil || !created {
t.Fatalf("first append: created=%v err=%v", created, err)
}
again, created, err := r.Append(ctx, p)
if err != nil {
t.Fatalf("retry: %v", err)
}
if created {
t.Error("retry reported created=true")
}
if again.ID != first.ID || again.Seq != first.Seq {
t.Errorf("retry returned a different message: %s/%d vs %s/%d", again.ID, again.Seq, first.ID, first.Seq)
}
var count, lastSeq int64
_ = pg.Pool.QueryRow(ctx, `SELECT count(*), max(seq) FROM messages WHERE conversation_id=$1`, c.ID).Scan(&count, &lastSeq)
if count != 1 || lastSeq != 1 {
t.Errorf("after retry: %d messages, last seq %d — want 1 and 1", count, lastSeq)
}
}
func TestMessages_KeysetPaging(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
for i := 1; i <= 5; i++ {
send(t, r, c.ID, a, fmt.Sprintf("m%d", i))
}
page1, err := r.Messages(ctx, c.ID, a, 0, 2)
if err != nil {
t.Fatal(err)
}
if len(page1) != 2 || page1[0].Seq != 5 || page1[1].Seq != 4 {
t.Fatalf("page1 = %v, want seq 5,4", seqsOf(page1))
}
page2, err := r.Messages(ctx, c.ID, a, page1[len(page1)-1].Seq, 2)
if err != nil {
t.Fatal(err)
}
if len(page2) != 2 || page2[0].Seq != 3 || page2[1].Seq != 2 {
t.Fatalf("page2 = %v, want seq 3,2", seqsOf(page2))
}
// entities must come back as a JSON array, never null — the client indexes
// into it without a nil check.
var spans []map[string]any
if err := json.Unmarshal(page1[0].Entities, &spans); err != nil {
t.Errorf("entities not valid JSON: %v (%s)", err, page1[0].Entities)
}
}
// Delete-for-me must hide a message from that user's history AND from their
// inbox preview, while leaving it visible to everyone else.
func TestMessageHides_ArePerUser(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
send(t, r, c.ID, a, "keep")
hidden := send(t, r, c.ID, a, "hide me")
if _, err := pg.Pool.Exec(ctx,
`INSERT INTO message_hides (message_id, user_id) VALUES ($1,$2)`, hidden.ID, a); err != nil {
t.Fatal(err)
}
mine, _ := r.Messages(ctx, c.ID, a, 0, 10)
if len(mine) != 1 || mine[0].Body != "keep" {
t.Errorf("hider still sees %v", bodiesOf(mine))
}
theirs, _ := r.Messages(ctx, c.ID, b, 0, 10)
if len(theirs) != 2 {
t.Errorf("other participant sees %v, want both", bodiesOf(theirs))
}
inbox, err := r.Inbox(ctx, a, []string{model.ConversationDM}, 10)
if err != nil {
t.Fatal(err)
}
row := findConv(inbox, c.ID)
if row == nil || row.LastMessage == nil {
t.Fatal("conversation missing from hider's inbox")
}
if row.LastMessage.Body != "keep" {
t.Errorf("hider's preview = %q, want the un-hidden message", row.LastMessage.Body)
}
}
func TestInbox_UnreadPeerAndPreview(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
send(t, r, c.ID, a, "first")
send(t, r, c.ID, a, "latest")
// Recipient has read nothing.
inbox, err := r.Inbox(ctx, b, []string{model.ConversationDM}, 10)
if err != nil {
t.Fatal(err)
}
row := findConv(inbox, c.ID)
if row == nil {
t.Fatal("thread missing from recipient inbox")
}
if row.Unread != 2 {
t.Errorf("unread = %d, want 2", row.Unread)
}
if row.LastMessage == nil || row.LastMessage.Body != "latest" {
t.Errorf("preview = %+v, want body %q", row.LastMessage, "latest")
}
if row.LastMessage != nil && row.LastMessage.SenderName == "" {
t.Error("preview has no sender name — the inbox line needs it")
}
if row.Peer == nil || row.Peer.ID != a {
t.Errorf("peer = %+v, want user A", row.Peer)
}
// The sender's own cursor advanced on send, so their unread is zero.
senderInbox, _ := r.Inbox(ctx, a, []string{model.ConversationDM}, 10)
if sr := findConv(senderInbox, c.ID); sr == nil || sr.Unread != 0 {
t.Errorf("sender unread = %v, want 0", sr)
}
if err := r.MarkRead(ctx, c.ID, b, 2); err != nil {
t.Fatal(err)
}
after, _ := r.Inbox(ctx, b, []string{model.ConversationDM}, 10)
if row := findConv(after, c.ID); row == nil || row.Unread != 0 {
t.Errorf("after MarkRead unread = %v, want 0", row)
}
}
// Cursors must never move backwards: a replayed or out-of-order receipt would
// otherwise resurrect already-read messages as unread.
func TestCursors_NeverGoBackwards(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b := makeUser(t, pg, "A"), makeUser(t, pg, "B")
c, err := r.EnsureDM(ctx, a, b)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, c.ID)
for i := 0; i < 3; i++ {
send(t, r, c.ID, a, "m")
}
if err := r.MarkRead(ctx, c.ID, b, 3); err != nil {
t.Fatal(err)
}
if err := r.MarkRead(ctx, c.ID, b, 1); err != nil { // stale replay
t.Fatal(err)
}
p, err := r.Participant(ctx, c.ID, b)
if err != nil {
t.Fatal(err)
}
if p.LastReadSeq != 3 {
t.Errorf("last_read_seq = %d after a stale replay, want 3", p.LastReadSeq)
}
if p.LastDeliveredSeq != 3 {
t.Errorf("reading should imply delivery: last_delivered_seq = %d, want 3", p.LastDeliveredSeq)
}
}
func TestReceipts_ExcludeCallerAndUseMinimum(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a, b, cUser := makeUser(t, pg, "A"), makeUser(t, pg, "B"), makeUser(t, pg, "C")
conv, err := r.CreateGroup(ctx, a, "Test group", []string{b, cUser})
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, conv.ID)
for i := 0; i < 3; i++ {
send(t, r, conv.ID, a, "m")
}
if err := r.MarkDelivered(ctx, conv.ID, b, 3); err != nil {
t.Fatal(err)
}
if err := r.MarkRead(ctx, conv.ID, b, 3); err != nil {
t.Fatal(err)
}
// C has received nothing yet.
rc, err := r.Receipts(ctx, conv.ID, a)
if err != nil {
t.Fatal(err)
}
if rc.ReadSeq != 0 || rc.DeliveredSeq != 0 {
t.Errorf("receipts = %+v, want zeros while one member is behind", rc)
}
if err := r.MarkRead(ctx, conv.ID, cUser, 3); err != nil {
t.Fatal(err)
}
rc, _ = r.Receipts(ctx, conv.ID, a)
if rc.ReadSeq != 3 || rc.DeliveredSeq != 3 {
t.Errorf("receipts = %+v, want 3/3 once everyone has read", rc)
}
}
func TestEnsureAIThread_OnePerUserAndSeparateTab(t *testing.T) {
r, pg := testRepo(t)
ctx := context.Background()
a := makeUser(t, pg, "A")
first, err := r.EnsureAIThread(ctx, a)
if err != nil {
t.Fatal(err)
}
dropConversation(t, pg, first.ID)
again, err := r.EnsureAIThread(ctx, a)
if err != nil {
t.Fatal(err)
}
if again.ID != first.ID {
t.Errorf("second call made another AI thread: %s vs %s", again.ID, first.ID)
}
// The tabs are a kind filter: an AI thread must not appear under Chats.
chats, _ := r.Inbox(ctx, a, []string{model.ConversationDM, model.ConversationGroup}, 10)
if findConv(chats, first.ID) != nil {
t.Error("AI thread leaked into the Chats tab")
}
kautilya, _ := r.Inbox(ctx, a, []string{model.ConversationAI}, 10)
if findConv(kautilya, first.ID) == nil {
t.Error("AI thread missing from the Kautilya tab")
}
}
func findConv(items []model.InboxItem, id string) *model.InboxItem {
for i := range items {
if items[i].ID == id {
return &items[i]
}
}
return nil
}
func seqsOf(msgs []model.Message) []int64 {
out := make([]int64, len(msgs))
for i, m := range msgs {
out[i] = m.Seq
}
return out
}
func bodiesOf(msgs []model.Message) []string {
out := make([]string, len(msgs))
for i, m := range msgs {
out[i] = m.Body
}
return out
}
+3 -3
View File
@@ -207,11 +207,11 @@ func (r *CollectionRepo) CollectionsContaining(ctx context.Context, postID, user
func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]model.Post, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url
p.created_at, p.updated_at, u.name, u.handle, u.avatar_url, p.is_hidden
FROM collection_items ci
JOIN posts p ON p.id = ci.post_id
JOIN users u ON u.id = p.user_id
WHERE ci.collection_id = $1
WHERE ci.collection_id = $1 AND p.deleted_at IS NULL
ORDER BY ci.position, ci.added_at`,
collectionID,
)
@@ -227,7 +227,7 @@ func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]mo
if err := rows.Scan(
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
&p.CreatedAt, &p.UpdatedAt,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL, &p.IsHidden,
); err != nil {
return nil, fmt.Errorf("scan post: %w", err)
}
+377
View File
@@ -0,0 +1,377 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
"go.uber.org/zap"
)
var (
// ErrCommentNotFound — no comment with that id (or not in the expected
// scope, e.g. pinning a reply).
ErrCommentNotFound = errors.New("comment not found")
// ErrDuplicateComment — the same user re-posted identical text in the same
// scope (caught by the dedup unique index).
ErrDuplicateComment = errors.New("duplicate comment")
)
// CommentRepo handles post comments, replies, comment-likes, and pinning.
// Comment likes are far lower-volume than post likes, so likes_count is kept
// in sync transactionally rather than batched through Redis.
type CommentRepo struct {
pg *Postgres
logger *zap.Logger
}
func NewCommentRepo(pg *Postgres, logger *zap.Logger) *CommentRepo {
return &CommentRepo{pg: pg, logger: logger}
}
// nullUUIDArg returns nil for an empty id so a `$n::uuid` parameter binds to
// SQL NULL (anonymous viewer) instead of failing uuid parsing on "".
func nullUUIDArg(s string) *string {
if s == "" {
return nil
}
return &s
}
// commentSelect is the shared column list for reading a comment with its
// author, the reply-to handle, the viewer's like state, and the reply count.
// `vl` is a viewer-scoped LEFT JOIN on comment_likes; `ru` the reply-to user.
const commentSelect = `
c.id, c.post_id, c.user_id, c.parent_id, c.body, c.likes_count,
c.pinned_at, c.created_at, c.updated_at,
u.name, u.handle, u.avatar_url,
ru.handle,
(vl.comment_id IS NOT NULL),
(SELECT count(*) FROM post_comments rc WHERE rc.parent_id = c.id AND rc.deleted_at IS NULL)`
// The viewer id always rides in parameter slot $2 across the read queries
// below, so the shared viewer-scoped like join can hardcode it.
const commentFrom = `
FROM post_comments c
JOIN users u ON u.id = c.user_id
LEFT JOIN users ru ON ru.id = c.reply_to_user_id
LEFT JOIN comment_likes vl ON vl.comment_id = c.id AND vl.user_id = $2::uuid`
func scanComment(row pgx.Row, c *model.Comment) error {
return row.Scan(&c.ID, &c.PostID, &c.UserID, &c.ParentID, &c.Body, &c.LikesCount,
&c.PinnedAt, &c.CreatedAt, &c.UpdatedAt,
&c.AuthorName, &c.AuthorHandle, &c.AuthorAvatarURL,
&c.ReplyToHandle,
&c.ViewerHasLiked, &c.ReplyCount)
}
func scanComments(rows pgx.Rows) ([]model.Comment, error) {
defer rows.Close()
out := []model.Comment{}
for rows.Next() {
var c model.Comment
if err := scanComment(rows, &c); err != nil {
return nil, fmt.Errorf("scan comment: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// PostAuthor returns the id of the user who owns the post, or ErrPostNotFound.
func (r *CommentRepo) PostAuthor(ctx context.Context, postID string) (string, error) {
var uid string
err := r.pg.Pool.QueryRow(ctx, `SELECT user_id FROM posts WHERE id = $1`, postID).Scan(&uid)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrPostNotFound
}
if err != nil {
return "", fmt.Errorf("post author: %w", err)
}
return uid, nil
}
// Create inserts a comment. parentID is the thread root (nil for a top-level
// comment); replyToUserID is who a reply addresses (nil for top-level).
// Returns ErrDuplicateComment when the same user re-posts identical text in
// the same scope. The returned Comment carries author fields.
func (r *CommentRepo) Create(ctx context.Context, postID, userID string, parentID, replyToUserID *string, body string) (*model.Comment, error) {
var id string
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO post_comments (post_id, user_id, parent_id, reply_to_user_id, body)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (post_id, user_id, COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), md5(body))
DO NOTHING
RETURNING id`,
postID, userID, parentID, replyToUserID, body,
).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrDuplicateComment
}
if err != nil {
return nil, fmt.Errorf("insert comment: %w", err)
}
// The author hasn't liked their own brand-new comment, so viewer state is
// resolved against userID (yields false) — fine for the create response.
return r.Get(ctx, id, userID)
}
// Get returns one comment hydrated with author + viewer-like + reply count.
func (r *CommentRepo) Get(ctx context.Context, id, viewerID string) (*model.Comment, error) {
q := `SELECT ` + commentSelect + commentFrom + ` WHERE c.id = $1 AND c.deleted_at IS NULL`
c := &model.Comment{}
err := scanComment(r.pg.Pool.QueryRow(ctx, q, id, nullUUIDArg(viewerID)), c)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrCommentNotFound
}
if err != nil {
return nil, fmt.Errorf("get comment: %w", err)
}
return c, nil
}
// ListTopLevel returns a page of a post's top-level comments — pinned first,
// then newest — plus the total top-level count.
func (r *CommentRepo) ListTopLevel(ctx context.Context, postID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
var total int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM post_comments WHERE post_id = $1 AND parent_id IS NULL AND deleted_at IS NULL`,
postID).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count comments: %w", err)
}
if total == 0 {
return []model.Comment{}, 0, nil
}
q := `SELECT ` + commentSelect + commentFrom + `
WHERE c.post_id = $1 AND c.parent_id IS NULL AND c.deleted_at IS NULL
ORDER BY (c.pinned_at IS NOT NULL) DESC, c.pinned_at DESC NULLS LAST, c.created_at DESC
LIMIT $3 OFFSET $4`
rows, err := r.pg.Pool.Query(ctx, q, postID, nullUUIDArg(viewerID), limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("list comments: %w", err)
}
out, err := scanComments(rows)
return out, total, err
}
// ListReplies returns a page of a thread's replies (oldest first) + the total.
func (r *CommentRepo) ListReplies(ctx context.Context, parentID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
var total int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM post_comments WHERE parent_id = $1 AND deleted_at IS NULL`, parentID).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count replies: %w", err)
}
if total == 0 {
return []model.Comment{}, 0, nil
}
q := `SELECT ` + commentSelect + commentFrom + `
WHERE c.parent_id = $1 AND c.deleted_at IS NULL
ORDER BY c.created_at ASC
LIMIT $3 OFFSET $4`
rows, err := r.pg.Pool.Query(ctx, q, parentID, nullUUIDArg(viewerID), limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("list replies: %w", err)
}
out, err := scanComments(rows)
return out, total, err
}
// Hydrate fills each post's comment summary — its pinned comment (if any) and
// total comment count (top-level + replies) — in two batched queries for the
// page. Drives the card footer's pinned line and comment count.
func (r *CommentRepo) Hydrate(ctx context.Context, viewerID string, posts []*model.Post) error {
ids := make([]string, 0, len(posts))
for _, p := range posts {
if p != nil {
ids = append(ids, p.ID)
}
}
if len(ids) == 0 {
return nil
}
// Pinned comment per post.
q := `SELECT ` + commentSelect + commentFrom + `
WHERE c.post_id = ANY($1) AND c.pinned_at IS NOT NULL`
rows, err := r.pg.Pool.Query(ctx, q, ids, nullUUIDArg(viewerID))
if err != nil {
return fmt.Errorf("hydrate pinned: %w", err)
}
list, err := scanComments(rows) // closes rows
if err != nil {
return err
}
pinnedByPost := make(map[string]*model.Comment, len(list))
for i := range list {
pinnedByPost[list[i].PostID] = &list[i]
}
// Total comment count per post (top-level + replies).
counts := make(map[string]int, len(ids))
crows, err := r.pg.Pool.Query(ctx,
`SELECT post_id, count(*) FROM post_comments WHERE post_id = ANY($1) AND deleted_at IS NULL GROUP BY post_id`,
ids)
if err != nil {
return fmt.Errorf("hydrate comment counts: %w", err)
}
defer crows.Close()
for crows.Next() {
var id string
var n int
if err := crows.Scan(&id, &n); err != nil {
return fmt.Errorf("scan comment count: %w", err)
}
counts[id] = n
}
if err := crows.Err(); err != nil {
return err
}
for _, p := range posts {
if p == nil {
continue
}
if c, ok := pinnedByPost[p.ID]; ok {
p.PinnedComment = c
}
p.CommentsCount = counts[p.ID]
}
return nil
}
// Like / Unlike toggle the viewer's like on a comment and return the new
// likes_count. Idempotent; the membership change and counter update happen in
// one transaction so concurrent likes stay consistent.
func (r *CommentRepo) Like(ctx context.Context, commentID, userID string) (int, error) {
return r.setLike(ctx, commentID, userID, true)
}
func (r *CommentRepo) Unlike(ctx context.Context, commentID, userID string) (int, error) {
return r.setLike(ctx, commentID, userID, false)
}
func (r *CommentRepo) setLike(ctx context.Context, commentID, userID string, like bool) (int, error) {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("begin like txn: %w", err)
}
defer tx.Rollback(ctx)
// Lock the comment row up front: this validates existence (clean 404
// instead of a leaked FK violation) and serializes concurrent likes so
// the counter stays exact.
var count int
if err := tx.QueryRow(ctx,
`SELECT likes_count FROM post_comments WHERE id = $1 FOR UPDATE`, commentID).Scan(&count); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrCommentNotFound
}
return 0, fmt.Errorf("read comment likes_count: %w", err)
}
if like {
tag, err := tx.Exec(ctx,
`INSERT INTO comment_likes (comment_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
commentID, userID)
if err != nil {
return 0, fmt.Errorf("insert comment like: %w", err)
}
if tag.RowsAffected() > 0 {
count++
if _, err := tx.Exec(ctx,
`UPDATE post_comments SET likes_count = $2 WHERE id = $1`, commentID, count); err != nil {
return 0, fmt.Errorf("bump comment likes: %w", err)
}
}
} else {
tag, err := tx.Exec(ctx,
`DELETE FROM comment_likes WHERE comment_id = $1 AND user_id = $2`, commentID, userID)
if err != nil {
return 0, fmt.Errorf("delete comment like: %w", err)
}
if tag.RowsAffected() > 0 {
if count > 0 {
count--
}
if _, err := tx.Exec(ctx,
`UPDATE post_comments SET likes_count = $2 WHERE id = $1`, commentID, count); err != nil {
return 0, fmt.Errorf("drop comment likes: %w", err)
}
}
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("commit like txn: %w", err)
}
return count, nil
}
// Pin makes commentID the post's single pinned comment (top-level only),
// unpinning any previously pinned comment in the same transaction. Returns
// ErrCommentNotFound when the comment isn't a top-level comment of postID.
func (r *CommentRepo) Pin(ctx context.Context, postID, commentID string) error {
tx, err := r.pg.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin pin txn: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx,
`UPDATE post_comments SET pinned_at = NULL WHERE post_id = $1 AND pinned_at IS NOT NULL`,
postID); err != nil {
return fmt.Errorf("unpin existing: %w", err)
}
tag, err := tx.Exec(ctx,
`UPDATE post_comments SET pinned_at = now(), updated_at = now()
WHERE id = $1 AND post_id = $2 AND parent_id IS NULL`,
commentID, postID)
if err != nil {
return fmt.Errorf("pin comment: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrCommentNotFound
}
return tx.Commit(ctx)
}
// Unpin clears the pin on commentID for postID.
func (r *CommentRepo) Unpin(ctx context.Context, postID, commentID string) error {
tag, err := r.pg.Pool.Exec(ctx,
`UPDATE post_comments SET pinned_at = NULL WHERE id = $1 AND post_id = $2`,
commentID, postID)
if err != nil {
return fmt.Errorf("unpin comment: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrCommentNotFound
}
return nil
}
// Delete removes the comment. For a thread root this cascades to its replies
// (and all comment_likes) via ON DELETE CASCADE. Authorization is the caller's
// responsibility.
func (r *CommentRepo) Delete(ctx context.Context, commentID string) error {
tag, err := r.pg.Pool.Exec(ctx, `DELETE FROM post_comments WHERE id = $1`, commentID)
if err != nil {
return fmt.Errorf("delete comment: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrCommentNotFound
}
return nil
}
// CreateReport files a report against a comment.
func (r *CommentRepo) CreateReport(ctx context.Context, reporterID, commentID, category, reason string) error {
if _, err := r.pg.Pool.Exec(ctx,
`INSERT INTO comment_reports (reporter_id, comment_id, category, reason)
VALUES ($1, $2, $3, $4)`,
reporterID, commentID, category, reason,
); err != nil {
return fmt.Errorf("create comment report: %w", err)
}
return nil
}
+55
View File
@@ -2,6 +2,7 @@ package repository
import (
"context"
"errors"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -88,6 +89,60 @@ func (r *DiscipleRepo) GetByLetterAndSuffix(ctx context.Context, letter *string,
return results, nil
}
// FindByIDs returns summary docs for the given ObjectID hex strings (order
// unspecified). Ids that aren't valid ObjectIDs are skipped rather than
// failing the batch — the ids come from a Postgres column with no FK, so a
// malformed or stale value must degrade gracefully. An empty/all-invalid
// input short-circuits without hitting Mongo.
//
// Used to hydrate disciple names onto admin user rows: the user→disciple link
// lives in Postgres while the disciple record lives here.
func (r *DiscipleRepo) FindByIDs(ctx context.Context, ids []string) ([]DiscipleSummaryDoc, error) {
oids := make([]bson.ObjectID, 0, len(ids))
for _, id := range ids {
if oid, err := bson.ObjectIDFromHex(id); err == nil {
oids = append(oids, oid)
}
}
if len(oids) == 0 {
return []DiscipleSummaryDoc{}, nil
}
cursor, err := r.col.Find(ctx,
bson.M{"_id": bson.M{"$in": oids}},
options.Find().SetProjection(discipleSummaryProjection),
)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var results []DiscipleSummaryDoc
if err := cursor.All(ctx, &results); err != nil {
return nil, err
}
return results, nil
}
// FindByID returns one disciple summary, or nil when the id is malformed or
// no such document exists. Callers use it to validate a link target before
// writing it to Postgres.
func (r *DiscipleRepo) FindByID(ctx context.Context, id string) (*DiscipleSummaryDoc, error) {
oid, err := bson.ObjectIDFromHex(id)
if err != nil {
return nil, nil // malformed id — "not found", not an error
}
var doc DiscipleSummaryDoc
err = r.col.FindOne(ctx, bson.M{"_id": oid},
options.FindOne().SetProjection(discipleSummaryProjection),
).Decode(&doc)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &doc, nil
}
func (r *DiscipleRepo) Count(ctx context.Context) (int64, error) {
return r.col.CountDocuments(ctx, bson.M{})
}
+76
View File
@@ -0,0 +1,76 @@
package repository
import (
"context"
"time"
"go.uber.org/zap"
)
// feedSeenTTL bounds a home-feed "session": after this idle window the
// per-user seen-set expires and the feed starts fresh from the top.
const feedSeenTTL = 30 * time.Minute
// FeedRepo tracks, per user, which post ids have already been served in the
// current home-feed session (a Redis SET) so paginated requests never repeat
// a post — and so the viewer-state-dependent feed ordering can't cause an
// OFFSET to drift over already-served rows.
//
// Nil-safe: with no Redis configured every method degrades to a no-op /
// empty, and the feed falls back to plain offset paging.
type FeedRepo struct {
redis *Redis
logger *zap.Logger
}
func NewFeedRepo(redis *Redis, logger *zap.Logger) *FeedRepo {
return &FeedRepo{redis: redis, logger: logger}
}
// Enabled reports whether the Redis-backed seen-set is available.
func (r *FeedRepo) Enabled() bool { return r != nil && r.redis != nil }
func (r *FeedRepo) key(userID string) string { return "feed:seen:" + userID }
// Seen returns the post ids already served to the user this session (empty
// when Redis is unavailable or the session is fresh).
func (r *FeedRepo) Seen(ctx context.Context, userID string) []string {
if !r.Enabled() {
return nil
}
ids, err := r.redis.Client.SMembers(ctx, r.key(userID)).Result()
if err != nil {
// Degrade rather than fail the feed — worst case is a possible repeat.
r.logger.Warn("feed seen: read failed", zap.String("user", userID), zap.Error(err))
return nil
}
return ids
}
// MarkSeen records the ids just served and refreshes the session TTL.
func (r *FeedRepo) MarkSeen(ctx context.Context, userID string, ids []string) {
if !r.Enabled() || len(ids) == 0 {
return
}
members := make([]any, len(ids))
for i, id := range ids {
members[i] = id
}
pipe := r.redis.Client.Pipeline()
pipe.SAdd(ctx, r.key(userID), members...)
pipe.Expire(ctx, r.key(userID), feedSeenTTL)
if _, err := pipe.Exec(ctx); err != nil {
r.logger.Warn("feed seen: write failed", zap.String("user", userID), zap.Error(err))
}
}
// Reset clears the user's seen-set — called when a fresh feed session begins
// (the first page request) so reopening the feed serves from the top again.
func (r *FeedRepo) Reset(ctx context.Context, userID string) {
if !r.Enabled() {
return
}
if err := r.redis.Client.Del(ctx, r.key(userID)).Err(); err != nil {
r.logger.Warn("feed seen: reset failed", zap.String("user", userID), zap.Error(err))
}
}
+43 -2
View File
@@ -85,15 +85,55 @@ func (r *FeedbackRepo) MarkResolved(ctx context.Context, id string) (resolved bo
return tag.RowsAffected() == 1, nil
}
// ListByUser returns a single user's own feedback submissions, newest
// first, with status timestamps and any admin reply. Backs the "My
// feedback" page under /settings. No author joins — the caller IS the
// author, so handle/name/email aren't needed here. Bounded limit keeps
// the query cheap.
func (r *FeedbackRepo) ListByUser(ctx context.Context, userID string, limit int) ([]model.Feedback, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT id, user_id, category, message, contact_email, created_at,
acknowledged_at, resolved_at, replied_at, reply_message
FROM feedback
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2`, userID, limit,
)
if err != nil {
return nil, fmt.Errorf("query user feedback: %w", err)
}
defer rows.Close()
out := []model.Feedback{}
for rows.Next() {
var f model.Feedback
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
&f.AcknowledgedAt, &f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage); err != nil {
return nil, fmt.Errorf("scan user feedback: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
// Recent returns the most-recent N feedback rows joined with users
// for display on /stats. NULL author columns mean anonymous (or the
// original submitter's account was since deleted).
//
// PRIVACY: /stats is a public page, so this query deliberately does NOT select
// contact_email — only a boolean saying whether one exists. Submitters' email
// addresses must never reach that template, and not fetching them makes that
// impossible rather than merely unlikely. Callers that legitimately need the
// address (the reply/thank-you emails) use FindByID instead.
func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback, error) {
if limit <= 0 || limit > 200 {
limit = 25
}
rows, err := r.pg.Pool.Query(ctx, `
SELECT f.id, f.user_id, f.category, f.message, f.contact_email, f.created_at,
SELECT f.id, f.user_id, f.category, f.message, f.created_at,
(f.contact_email IS NOT NULL AND f.contact_email <> '') AS has_contact_email,
f.acknowledged_at, f.resolved_at,
u.handle, u.name
FROM feedback f
@@ -108,7 +148,8 @@ func (r *FeedbackRepo) Recent(ctx context.Context, limit int) ([]model.Feedback,
out := []model.Feedback{}
for rows.Next() {
var f model.Feedback
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
if err := rows.Scan(&f.ID, &f.UserID, &f.Category, &f.Message, &f.CreatedAt,
&f.HasContactEmail,
&f.AcknowledgedAt, &f.ResolvedAt,
&f.AuthorHandle, &f.AuthorName); err != nil {
return nil, fmt.Errorf("scan feedback: %w", err)
+165
View File
@@ -0,0 +1,165 @@
package repository
import (
"context"
"fmt"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// maxMentionsPerPost caps how many users a post can tag.
const maxMentionsPerPost = 30
// MentionRepo handles post_mentions membership + mention suggestions.
type MentionRepo struct{ pg *Postgres }
func NewMentionRepo(pg *Postgres) *MentionRepo { return &MentionRepo{pg: pg} }
// Suggest returns up to `limit` users to mention, prioritised: people the
// viewer follows (rank 0), then people who follow the viewer (rank 1), then
// everyone else (rank 2). Excludes the viewer. Optional name/handle search.
// Offset paginates for the infinite-scroll popover.
func (r *MentionRepo) Suggest(ctx context.Context, viewerID, q string, limit, offset int) ([]model.UserSummary, error) {
like := "%" + q + "%"
rows, err := r.pg.Pool.Query(ctx, `
SELECT u.id, u.handle, u.name, u.avatar_url,
CASE
WHEN EXISTS (SELECT 1 FROM follows f
WHERE f.follower_id = $1 AND f.following_id = u.id
AND f.status = 'accepted') THEN 0
WHEN EXISTS (SELECT 1 FROM follows f
WHERE f.follower_id = u.id AND f.following_id = $1
AND f.status = 'accepted') THEN 1
ELSE 2
END AS rank
FROM users u
WHERE u.id <> $1
AND ($2 = '' OR u.name ILIKE $3 OR u.handle ILIKE $3)
ORDER BY rank, u.name, u.id
LIMIT $4 OFFSET $5`,
viewerID, q, like, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("suggest mentions: %w", err)
}
defer rows.Close()
out := make([]model.UserSummary, 0, limit)
for rows.Next() {
var s model.UserSummary
var rank int
if err := rows.Scan(&s.ID, &s.Handle, &s.Name, &s.AvatarURL, &rank); err != nil {
return nil, fmt.Errorf("scan suggestion: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
// AddMany tags the given users on a post (idempotent, capped). Used on create.
func (r *MentionRepo) AddMany(ctx context.Context, postID string, userIDs []string) error {
if len(userIDs) > maxMentionsPerPost {
userIDs = userIDs[:maxMentionsPerPost]
}
for _, uid := range userIDs {
if _, err := r.pg.Pool.Exec(ctx,
`INSERT INTO post_mentions (post_id, user_id) VALUES ($1, $2)
ON CONFLICT (post_id, user_id) DO NOTHING`,
postID, uid,
); err != nil {
return fmt.Errorf("add mention: %w", err)
}
}
return nil
}
// Add tags a single user (idempotent), enforcing the per-post cap.
func (r *MentionRepo) Add(ctx context.Context, postID, userID string) error {
var n int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM post_mentions WHERE post_id = $1`, postID,
).Scan(&n); err != nil {
return fmt.Errorf("count mentions: %w", err)
}
if n >= maxMentionsPerPost {
return fmt.Errorf("a post can mention at most %d people", maxMentionsPerPost)
}
if _, err := r.pg.Pool.Exec(ctx,
`INSERT INTO post_mentions (post_id, user_id) VALUES ($1, $2)
ON CONFLICT (post_id, user_id) DO NOTHING`,
postID, userID,
); err != nil {
return fmt.Errorf("add mention: %w", err)
}
return nil
}
// Remove untags a user from a post.
func (r *MentionRepo) Remove(ctx context.Context, postID, userID string) error {
if _, err := r.pg.Pool.Exec(ctx,
`DELETE FROM post_mentions WHERE post_id = $1 AND user_id = $2`,
postID, userID,
); err != nil {
return fmt.Errorf("remove mention: %w", err)
}
return nil
}
// ListForPost returns the users tagged on a post (insertion order).
func (r *MentionRepo) ListForPost(ctx context.Context, postID string) ([]model.UserSummary, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT u.id, u.handle, u.name, u.avatar_url
FROM post_mentions m JOIN users u ON u.id = m.user_id
WHERE m.post_id = $1
ORDER BY m.created_at ASC`, postID)
if err != nil {
return nil, fmt.Errorf("list mentions: %w", err)
}
defer rows.Close()
out := []model.UserSummary{}
for rows.Next() {
var s model.UserSummary
if err := rows.Scan(&s.ID, &s.Handle, &s.Name, &s.AvatarURL); err != nil {
return nil, fmt.Errorf("scan mention: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
// HydrateCounts fills MentionsCount on the given posts (one grouped query).
func (r *MentionRepo) HydrateCounts(ctx context.Context, posts []*model.Post) error {
ids := make([]string, 0, len(posts))
for _, p := range posts {
if p != nil {
ids = append(ids, p.ID)
}
}
if len(ids) == 0 {
return nil
}
rows, err := r.pg.Pool.Query(ctx,
`SELECT post_id, count(*) FROM post_mentions WHERE post_id = ANY($1) GROUP BY post_id`, ids)
if err != nil {
return fmt.Errorf("hydrate mention counts: %w", err)
}
counts := make(map[string]int, len(ids))
for rows.Next() {
var id string
var c int
if err := rows.Scan(&id, &c); err != nil {
rows.Close()
return fmt.Errorf("scan mention count: %w", err)
}
counts[id] = c
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, p := range posts {
if p != nil {
p.MentionsCount = counts[p.ID]
}
}
return nil
}
@@ -0,0 +1,99 @@
package repository
import (
"context"
"fmt"
)
// Notification delivery channels (notification_prefs.channel values).
const (
ChannelInApp = "in_app"
ChannelEmail = "email"
ChannelPush = "push"
)
// PrefKey is the lookup key for a (kind, channel) cell in a DisabledSet.
func PrefKey(kind, channel string) string { return kind + "/" + channel }
// NotificationPrefsRepo stores per-user notification opt-outs: one row per
// (kind, channel) the user has turned OFF. Absence of a row means enabled,
// so the table only ever holds overrides and new kinds/channels default on.
type NotificationPrefsRepo struct{ pg *Postgres }
func NewNotificationPrefsRepo(pg *Postgres) *NotificationPrefsRepo {
return &NotificationPrefsRepo{pg: pg}
}
// DisabledSet returns the user's opt-outs keyed by PrefKey(kind, channel).
func (r *NotificationPrefsRepo) DisabledSet(ctx context.Context, userID string) (map[string]struct{}, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT kind, channel FROM notification_prefs
WHERE user_id = $1 AND enabled = FALSE`, userID)
if err != nil {
return nil, fmt.Errorf("list notification prefs: %w", err)
}
defer rows.Close()
out := map[string]struct{}{}
for rows.Next() {
var kind, channel string
if err := rows.Scan(&kind, &channel); err != nil {
return nil, fmt.Errorf("scan notification pref: %w", err)
}
out[PrefKey(kind, channel)] = struct{}{}
}
return out, rows.Err()
}
// Disable records opt-out rows for every kind × channel pair.
func (r *NotificationPrefsRepo) Disable(ctx context.Context, userID string, kinds, channels []string) error {
_, err := r.pg.Pool.Exec(ctx, `
INSERT INTO notification_prefs (user_id, kind, channel, enabled)
SELECT $1, k, c, FALSE
FROM unnest($2::text[]) AS k, unnest($3::text[]) AS c
ON CONFLICT (user_id, kind, channel)
DO UPDATE SET enabled = FALSE, updated_at = now()`,
userID, kinds, channels)
if err != nil {
return fmt.Errorf("disable notification prefs: %w", err)
}
return nil
}
// Enable removes opt-out rows (absence = enabled) for every kind × channel
// pair.
func (r *NotificationPrefsRepo) Enable(ctx context.Context, userID string, kinds, channels []string) error {
_, err := r.pg.Pool.Exec(ctx, `
DELETE FROM notification_prefs
WHERE user_id = $1 AND kind = ANY($2::text[]) AND channel = ANY($3::text[])`,
userID, kinds, channels)
if err != nil {
return fmt.Errorf("enable notification prefs: %w", err)
}
return nil
}
// SyncLegacyColumns recomputes users.like_emails_enabled and
// users.sadhna_reminders_enabled from the prefs table, so an old frontend
// (or a rollback) reading the legacy columns stays correct. Call after any
// prefs write; drop together with the columns once fully rolled out.
func (r *NotificationPrefsRepo) SyncLegacyColumns(ctx context.Context, userID string) error {
_, err := r.pg.Pool.Exec(ctx, `
UPDATE users SET
like_emails_enabled = NOT EXISTS (
SELECT 1 FROM notification_prefs np
WHERE np.user_id = users.id
AND np.kind = 'post_like' AND np.channel = 'email'
AND np.enabled = FALSE),
-- The legacy reminder toggle was all-or-nothing: it reads as OFF
-- only when every reminder kind × channel cell is opted out.
sadhna_reminders_enabled = (
SELECT count(*) FROM notification_prefs np
WHERE np.user_id = users.id
AND np.kind IN ('chant_reminder', 'quote_reminder', 'chant_incomplete_reminder')
AND np.enabled = FALSE) < 9
WHERE id = $1`, userID)
if err != nil {
return fmt.Errorf("sync legacy notification prefs: %w", err)
}
return nil
}
+229 -4
View File
@@ -36,7 +36,8 @@ const postSelectColumns = `
p.reshared_from_post_id,
ou.name AS original_author_name,
ou.handle AS original_author_handle,
ou.avatar_url AS original_author_avatar
ou.avatar_url AS original_author_avatar,
p.is_hidden
`
const postSelectFrom = `
@@ -58,6 +59,7 @@ func scanPost(row interface {
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
&p.ResharedFromPostID,
&p.OriginalAuthorName, &p.OriginalAuthorHandle, &p.OriginalAuthorAvatar,
&p.IsHidden,
)
}
@@ -142,7 +144,7 @@ func (r *PostRepo) ReplaceImages(ctx context.Context, postID, userID string, ima
// FindByID returns one post with its images and author info (plus original
// author info if this row is a reshare).
func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1`
q := `SELECT ` + postSelectColumns + postSelectFrom + `WHERE p.id = $1 AND p.deleted_at IS NULL`
p := &model.Post{}
if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -171,22 +173,215 @@ func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offse
return r.ListByUser(ctx, userID, limit, offset)
}
// ListMentioningByHandle returns posts that tag the given user (handle),
// newest first — the "Mentions" profile tab. Same shape as the profile feed.
func (r *PostRepo) ListMentioningByHandle(ctx context.Context, handle string, limit, offset int) ([]model.Post, int, error) {
var userID string
if err := r.pg.Pool.QueryRow(ctx, `SELECT id FROM users WHERE handle = $1`, handle).Scan(&userID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return []model.Post{}, 0, nil
}
return nil, 0, fmt.Errorf("lookup user by handle: %w", err)
}
// Hidden posts are dropped from the mentions timeline too (the mentioned
// user is not the author, so there is no owner exception here).
var total int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM posts p JOIN post_mentions pm ON pm.post_id = p.id WHERE pm.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL`,
userID,
).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count mentioning posts: %w", err)
}
q := `SELECT ` + postSelectColumns + postSelectFrom + `
JOIN post_mentions pm ON pm.post_id = p.id
WHERE pm.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL
ORDER BY p.created_at DESC, p.id
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, userID, limit, offset)
}
// ListByUser returns the user's profile feed — every post they own,
// authored or reshared. Reshares are interleaved naturally because
// they're stored as real rows; sort by created_at gets the right order
// without any UNION gymnastics.
//
// Hidden posts are excluded from this timeline for everyone, including the
// owner — they live in the owner's separate "Hidden" list (ListHiddenByUser).
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
total, err := r.CountByUser(ctx, userID)
if err != nil {
return nil, 0, err
}
// id DESC tiebreak must match ListByUserKeyset so the scroll feed's first
// (offset) page and its keyset continuation agree on the order of
// same-created_at posts — otherwise the keyset page re-returns already-shown
// rows, the client's cursor never advances, and it loops forever.
q := `SELECT ` + postSelectColumns + postSelectFrom + `
WHERE p.user_id = $1
WHERE p.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL
ORDER BY p.created_at DESC, p.id DESC
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, userID, limit, offset)
}
// ListByUserKeyset returns the user's visible posts older than the cursor
// (created_at, id), newest-first — keyset pagination for the Instagram-style
// scroll feed. A zero beforeAt returns the newest page. No total is computed
// (infinite scroll infers "more" from a full page, like FeedForViewer). The
// (created_at, id) DESC ordering matches the tuple comparison so paging never
// skips or repeats a row even when timestamps tie.
func (r *PostRepo) ListByUserKeyset(ctx context.Context, userID string, beforeAt time.Time, beforeID string, limit int) ([]model.Post, error) {
base := `SELECT ` + postSelectColumns + postSelectFrom + `
WHERE p.user_id = $1 AND p.is_hidden = FALSE AND p.deleted_at IS NULL`
if beforeAt.IsZero() {
q := base + `
ORDER BY p.created_at DESC, p.id DESC
LIMIT $2`
posts, _, err := r.collectPosts(ctx, 0, q, userID, limit)
return posts, err
}
q := base + `
AND (p.created_at < $2 OR (p.created_at = $2 AND p.id < $3))
ORDER BY p.created_at DESC, p.id DESC
LIMIT $4`
posts, _, err := r.collectPosts(ctx, 0, q, userID, beforeAt, beforeID, limit)
return posts, err
}
// ListHiddenByUser returns the owner's hidden posts, newest first — the
// /u/me "Hidden" tab. Owner-only (the caller enforces auth + scoping).
func (r *PostRepo) ListHiddenByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
total, err := r.CountHiddenByUser(ctx, userID)
if err != nil {
return nil, 0, err
}
q := `SELECT ` + postSelectColumns + postSelectFrom + `
WHERE p.user_id = $1 AND p.is_hidden = TRUE AND p.deleted_at IS NULL
ORDER BY p.created_at DESC, p.id
LIMIT $2 OFFSET $3`
return r.collectPosts(ctx, total, q, userID, limit, offset)
}
// FeedForViewer returns one page of the global home feed for `viewerID`,
// newest-first within a two-tier priority:
//
// tier 0 — posts by people the viewer follows that they haven't liked yet
// tier 1 — everything else (followed-and-liked, followers', strangers')
//
// The viewer's own posts, anything involving a block (either direction), and
// any id in `exclude` (the already-served set) are filtered out. The tier is
// computed in ORDER BY via correlated EXISTS, so no extra columns leak into
// scanPost. No total is computed (infinite scroll infers "more" from a full
// page).
//
// With a non-empty `exclude` the caller drives pagination purely by exclusion
// (pass offset 0): since the tier depends on viewer state — liking a post
// moves it between tiers — an OFFSET would drift and repeat/skip rows, so the
// seen-set is the correct cursor. Without Redis, `exclude` is empty and OFFSET
// paginates (the frontend de-dups the rare drift). At larger scale this moves
// to a precomputed fan-out / keyset cursor.
func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, offset int, exclude []string) ([]model.Post, error) {
// nil would encode as SQL NULL, and `id <> ALL(NULL)` is NULL (excludes
// every row); a non-nil empty array means "exclude nothing".
if exclude == nil {
exclude = []string{}
}
q := `SELECT ` + postSelectColumns + postSelectFrom + `
WHERE p.user_id <> $1
AND p.is_hidden = FALSE AND p.deleted_at IS NULL
AND p.id <> ALL($4)
AND NOT EXISTS (
SELECT 1 FROM user_blocks b
WHERE (b.blocker_id = $1 AND b.blocked_id = p.user_id)
OR (b.blocker_id = p.user_id AND b.blocked_id = $1)
)
ORDER BY
(CASE
WHEN EXISTS (
SELECT 1 FROM follows f
WHERE f.follower_id = $1 AND f.following_id = p.user_id
AND f.status = 'accepted'
)
AND NOT EXISTS (
SELECT 1 FROM post_likes pl
WHERE pl.post_id = p.id AND pl.user_id = $1
)
THEN 0 ELSE 1
END) ASC,
p.created_at DESC, p.id DESC
LIMIT $2 OFFSET $3`
posts, _, err := r.collectPosts(ctx, 0, q, viewerID, limit, offset, exclude)
return posts, err
}
// HydrateViewerRelationships fills ViewerFollowsAuthor / AuthorFollowsViewer /
// ViewerFollowRequested across a page of feed posts in three batched queries
// (viewer→author accepted, author→viewer accepted, viewer→author pending).
// No-op for anonymous viewers.
func (r *PostRepo) HydrateViewerRelationships(ctx context.Context, viewerID string, posts []model.Post) error {
if viewerID == "" || len(posts) == 0 {
return nil
}
seen := map[string]bool{}
authorIDs := make([]string, 0, len(posts))
for i := range posts {
id := posts[i].UserID
if id != "" && id != viewerID && !seen[id] {
seen[id] = true
authorIDs = append(authorIDs, id)
}
}
if len(authorIDs) == 0 {
return nil
}
following, err := r.followEdges(ctx,
`SELECT following_id FROM follows
WHERE follower_id = $1 AND status = 'accepted' AND following_id = ANY($2)`,
viewerID, authorIDs)
if err != nil {
return err
}
followers, err := r.followEdges(ctx,
`SELECT follower_id FROM follows
WHERE following_id = $1 AND status = 'accepted' AND follower_id = ANY($2)`,
viewerID, authorIDs)
if err != nil {
return err
}
requested, err := r.followEdges(ctx,
`SELECT following_id FROM follows
WHERE follower_id = $1 AND status = 'pending' AND following_id = ANY($2)`,
viewerID, authorIDs)
if err != nil {
return err
}
for i := range posts {
posts[i].ViewerFollowsAuthor = following[posts[i].UserID]
posts[i].AuthorFollowsViewer = followers[posts[i].UserID]
posts[i].ViewerFollowRequested = requested[posts[i].UserID]
}
return nil
}
// followEdges runs a one-column follow query and collects the ids into a set.
func (r *PostRepo) followEdges(ctx context.Context, query, viewerID string, ids []string) (map[string]bool, error) {
rows, err := r.pg.Pool.Query(ctx, query, viewerID, ids)
if err != nil {
return nil, fmt.Errorf("hydrate follow edges: %w", err)
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan follow edge: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
// Delete removes a post only if it belongs to the caller. Cascades:
// - the post's images (post_images.post_id FK)
// - any reshares of this post made by other users (posts.reshared_from_post_id FK)
@@ -203,12 +398,29 @@ func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
return nil
}
// SetHidden flips a post's hidden flag. Owner-only: the WHERE clause scopes
// to user_id, so a non-owner (or unknown id) affects no rows → ErrPostNotFound.
func (r *PostRepo) SetHidden(ctx context.Context, postID, userID string, hidden bool) error {
tag, err := r.pg.Pool.Exec(ctx,
`UPDATE posts SET is_hidden = $3, updated_at = now() WHERE id = $1 AND user_id = $2`,
postID, userID, hidden)
if err != nil {
return fmt.Errorf("set post hidden: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrPostNotFound
}
return nil
}
// CountByUser returns the user's total post count (authored + reshared).
// Now just a single COUNT against posts.user_id.
// CountByUser counts the user's visible (non-hidden) posts — matches the
// timeline shown on their profile.
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM posts WHERE user_id = $1`, userID,
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = FALSE AND deleted_at IS NULL`, userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count posts: %w", err)
@@ -216,6 +428,19 @@ func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error)
return n, nil
}
// CountHiddenByUser counts the owner's hidden posts (drives the "Hidden" tab's
// visibility + badge).
func (r *PostRepo) CountHiddenByUser(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM posts WHERE user_id = $1 AND is_hidden = TRUE AND deleted_at IS NULL`, userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count hidden posts: %w", err)
}
return n, nil
}
// PostedDates returns the set of UTC calendar days, on or after `since`,
// on which the user created at least one post. Used by the sādhana streak
// to detect the "posted a quote today" discipline (and to score past days
+15 -2
View File
@@ -74,8 +74,9 @@ func (r *PushRepo) PruneToMostRecent(ctx context.Context, userID string, keep in
return nil
}
// DeleteByEndpoint removes a subscription (on unsubscribe or when the push
// service reports it gone).
// 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)
@@ -84,3 +85,15 @@ func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error
}
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
}
+100 -3
View File
@@ -25,6 +25,11 @@ type R2 struct {
client *s3.Client
bucket string
publicBase string // R2_PUBLIC_URL, no trailing slash
// privateBucket is a separate, non-public bucket for objects that must
// only be reachable through a presigned GET (see PresignGet). Empty when
// unconfigured, which makes PrivateConfigured report false.
privateBucket string
}
// R2Config captures the env vars needed to talk to R2.
@@ -34,6 +39,9 @@ type R2Config struct {
SecretAccessKey string
Bucket string
PublicURL string
// PrivateBucket is optional: absent means private media is disabled,
// while the public bucket keeps working.
PrivateBucket string
}
// Configured returns true if every required field is present.
@@ -61,9 +69,10 @@ func NewR2(cfg R2Config) (*R2, error) {
})
return &R2{
client: client,
bucket: cfg.Bucket,
publicBase: trimTrailingSlash(cfg.PublicURL),
client: client,
bucket: cfg.Bucket,
publicBase: trimTrailingSlash(cfg.PublicURL),
privateBucket: cfg.PrivateBucket,
}, nil
}
@@ -129,3 +138,91 @@ func trimTrailingSlash(s string) string {
}
return s
}
// ---------------------------------------------------------------------------
// Private media
//
// Objects in the public bucket are world-readable by URL — fine for avatars
// and post images, wrong for anything private (DM attachments). R2 public
// access is per-bucket, not per-prefix, so private objects live in a
// SEPARATE bucket that has no public access and no custom domain. The only
// way to read them is a short-lived presigned GET, which the BFF issues after
// checking that the caller is allowed to see that particular object.
// ---------------------------------------------------------------------------
// PrivateConfigured reports whether the private bucket is available. Callers
// use it to return 503 rather than silently falling back to public storage —
// a fallback there would leak the very objects this bucket exists to protect.
func (r *R2) PrivateConfigured() bool {
return r != nil && r.privateBucket != ""
}
// PresignPutPrivate returns a one-shot PUT URL for the PRIVATE bucket.
//
// Unlike PresignPut it deliberately returns no public URL: there isn't one,
// and handing callers a URL-shaped string for a private object invites it
// being persisted and treated as fetchable. Store the key instead, and mint a
// read URL per request with PresignGet.
func (r *R2) PresignPutPrivate(ctx context.Context, key, contentType string, expiry time.Duration) (uploadURL string, err error) {
if !r.PrivateConfigured() {
return "", errors.New("private media storage is not configured")
}
if key == "" {
return "", errors.New("empty key")
}
presigner := s3.NewPresignClient(r.client)
req, err := presigner.PresignPutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(r.privateBucket),
Key: aws.String(key),
ContentType: aws.String(contentType),
}, s3.WithPresignExpires(expiry))
if err != nil {
return "", fmt.Errorf("presign private put: %w", err)
}
return req.URL, nil
}
// PresignGet returns a short-lived read URL for an object in the private
// bucket.
//
// Keep expiry short. The URL is a bearer credential for that one object:
// anyone holding it can read until it expires, so it should be minted per
// request and never persisted in a message payload or an offline cache.
func (r *R2) PresignGet(ctx context.Context, key string, expiry time.Duration) (string, error) {
if !r.PrivateConfigured() {
return "", errors.New("private media storage is not configured")
}
if key == "" {
return "", errors.New("empty key")
}
presigner := s3.NewPresignClient(r.client)
req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(r.privateBucket),
Key: aws.String(key),
}, s3.WithPresignExpires(expiry))
if err != nil {
return "", fmt.Errorf("presign get: %w", err)
}
return req.URL, nil
}
// DeletePrivate removes an object from the private bucket. Retention and
// account deletion need real erasure, not a tombstone row that leaves the
// bytes sitting in storage.
func (r *R2) DeletePrivate(ctx context.Context, key string) error {
if !r.PrivateConfigured() {
return errors.New("private media storage is not configured")
}
if key == "" {
return errors.New("empty key")
}
if _, err := r.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(r.privateBucket),
Key: aws.String(key),
}); err != nil {
return fmt.Errorf("delete private object %q: %w", key, err)
}
return nil
}
+9 -22
View File
@@ -44,30 +44,14 @@ func (r *ReminderRepo) SetTimezone(ctx context.Context, userID, tz string) error
return nil
}
// GetEnabled returns whether the user wants sādhana reminders.
func (r *ReminderRepo) GetEnabled(ctx context.Context, userID string) (bool, error) {
var enabled bool
err := r.pg.Pool.QueryRow(ctx,
`SELECT sadhna_reminders_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
if err != nil {
return false, fmt.Errorf("get reminder pref: %w", err)
}
return enabled, nil
}
// SetEnabled flips the user's reminder opt-in.
func (r *ReminderRepo) SetEnabled(ctx context.Context, userID string, enabled bool) error {
_, err := r.pg.Pool.Exec(ctx,
`UPDATE users SET sadhna_reminders_enabled = $1 WHERE id = $2`, enabled, userID)
if err != nil {
return fmt.Errorf("set reminder pref: %w", err)
}
return nil
}
// DueCandidates returns reminder-enabled users who haven't already had BOTH
// reminders today, with the data the worker needs to decide. Defaults are
// folded in (06:00 chant, 21:30 quote, UTC tz).
//
// "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
@@ -92,7 +76,10 @@ func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate,
COALESCE(u.daily_chanting_rounds, 16),
u.last_chant_incomplete_reminder_on
FROM users u
WHERE u.sadhna_reminders_enabled = TRUE
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
+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
}
}
+177 -29
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
@@ -97,22 +98,29 @@ func (r *FollowRepo) Status(ctx context.Context, followerID, followingID string)
// them), newest first, optionally filtered by a name/handle search `q`.
// Returns the page plus the total matching count (for pagination).
func (r *FollowRepo) ListFollowers(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
return r.listRelations(ctx, "f.following_id = $1", "f.follower_id", userID, q, limit, offset)
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", userID, q, limit, offset)
return r.listRelations(ctx, "f.follower_id = $1", "f.following_id", "accepted", userID, q, limit, offset)
}
// listRelations powers both directions: `whereOwn` pins the row to userID on
// one side, `joinCol` is the follows column holding the *other* user (the one
// we join to users and return). `q` filters by name/handle (ILIKE); empty `q`
// disables the filter. The column expressions are fixed internal constants —
// never user input — so this stays injection-safe.
// 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, userID, q string, limit, offset int,
ctx context.Context, whereOwn, joinCol, status, userID, q string, limit, offset int,
) ([]model.UserSummary, int, error) {
like := "%" + q + "%"
@@ -120,7 +128,7 @@ func (r *FollowRepo) listRelations(
countSQL := `
SELECT count(*)
FROM follows f JOIN users u ON u.id = ` + joinCol + `
WHERE ` + whereOwn + ` AND f.status = 'accepted'
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)
@@ -132,7 +140,7 @@ func (r *FollowRepo) listRelations(
listSQL := `
SELECT u.handle, u.name, u.avatar_url
FROM follows f JOIN users u ON u.id = ` + joinCol + `
WHERE ` + whereOwn + ` AND f.status = 'accepted'
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`
@@ -314,17 +322,94 @@ func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *s
return n, nil
}
// CreateAdminMessage persists an admin → user direct message. Unlike the
// event-driven kinds it has no actor and carries free text in `body`.
func (r *NotificationRepo) CreateAdminMessage(ctx context.Context, userID, body string) error {
if _, err := r.pg.Pool.Exec(ctx,
`INSERT INTO notifications (user_id, kind, body) VALUES ($1, 'admin_message', $2)`,
userID, body,
); err != nil {
return fmt.Errorf("create admin message notification: %w", err)
}
return nil
}
// BroadcastToAllUsers writes one notification of `kind` to every active user
// except `actorID`, in a single INSERT…SELECT. One round trip regardless of
// audience size — the alternative (a row per user from Go) would be hundreds
// of round trips on the request path.
//
// Deliberately does NOT filter on notification_prefs: ListByUser already
// excludes kinds the reader has disabled for the in_app channel at read time,
// so writing the rows unconditionally keeps a re-enabled preference able to
// surface history — exactly how the existing kinds behave.
//
// Banned accounts are skipped; they can't sign in to read it anyway.
func (r *NotificationRepo) BroadcastToAllUsers(ctx context.Context, kind, actorID string) (int64, error) {
tag, err := r.pg.Pool.Exec(ctx, `
INSERT INTO notifications (user_id, actor_id, kind)
SELECT u.id, $2, $1
FROM users u
WHERE u.id <> $2 AND u.banned_at IS NULL`, kind, actorID)
if err != nil {
return 0, fmt.Errorf("broadcast notification: %w", err)
}
return tag.RowsAffected(), nil
}
// PushRecipientsForKind lists the ids of users who should receive a *push* for
// `kind` — everyone except the actor, minus banned accounts, minus anyone who
// turned this kind off for the push channel, and minus anyone with no push
// subscription registered at all.
//
// That last filter is what keeps a broadcast cheap: only devotees who actually
// enabled web push are worth iterating, which is typically a small fraction of
// the user base.
func (r *NotificationRepo) PushRecipientsForKind(ctx context.Context, kind, actorID string) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT DISTINCT u.id
FROM users u
JOIN push_subscriptions ps ON ps.user_id = u.id
WHERE u.id <> $2 AND u.banned_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM notification_prefs np
WHERE np.user_id = u.id AND np.kind = $1
AND np.channel = 'push' AND np.enabled = FALSE)`, kind, actorID)
if err != nil {
return nil, fmt.Errorf("list push recipients: %w", err)
}
defer rows.Close()
out := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan push recipient: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
// ListByUser returns a recipient's notifications, newest first, with the
// actor's display fields joined in for rendering (nil for system
// notifications or since-deleted actors).
// notifications or since-deleted actors). Kinds the user has turned off for
// the in-app channel are filtered at read time — the rows still persist (they
// back like-aggregation and reappear if the pref is re-enabled).
func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count,
n.created_at, n.read_at,
u.name, u.handle, u.avatar_url
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,
)
@@ -337,7 +422,8 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
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); err != nil {
&n.ActorName, &n.ActorHandle, &n.ActorAvatar,
&n.PostAuthorHandle, &n.Body); err != nil {
return nil, fmt.Errorf("scan notification: %w", err)
}
out = append(out, n)
@@ -347,32 +433,94 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
// 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. Returns the
// new actor_count.
func (r *NotificationRepo) UpsertLike(ctx context.Context, userID, actorID, postID string) (int, error) {
var count int
err := r.pg.Pool.QueryRow(ctx, `
INSERT INTO notifications (user_id, actor_id, kind, post_id, actor_count)
VALUES ($1, $2, 'post_like', $3, 1)
// 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()
RETURNING actor_count`,
userID, actorID, postID,
).Scan(&count)
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, fmt.Errorf("upsert like notification: %w", err)
return 0, false, fmt.Errorf("upsert like notification: %w", err)
}
return count, nil
return count, emailDue, nil
}
// CountUnread returns how many of a recipient's notifications are unread.
// CreateMention persists a "you were mentioned" notification linked to the
// post. Unlike likes there's no aggregation — a mention is a deliberate,
// low-volume event.
func (r *NotificationRepo) CreateMention(ctx context.Context, userID, actorID, postID string) error {
if _, err := r.pg.Pool.Exec(ctx,
`INSERT INTO notifications (user_id, actor_id, kind, post_id)
VALUES ($1, $2, 'post_mention', $3)`,
userID, actorID, postID,
); err != nil {
return fmt.Errorf("create mention notification: %w", err)
}
return nil
}
// CreateComment persists a comment/reply notification linked to the post.
// kind is 'post_comment' (to the post author) or 'comment_reply' (to the
// replied-to comment's author). Like mentions, no aggregation.
func (r *NotificationRepo) CreateComment(ctx context.Context, userID, actorID, postID, kind string) error {
if _, err := r.pg.Pool.Exec(ctx,
`INSERT INTO notifications (user_id, actor_id, kind, post_id)
VALUES ($1, $2, $3, $4)`,
userID, actorID, kind, postID,
); err != nil {
return fmt.Errorf("create comment notification: %w", err)
}
return nil
}
// PostAuthorHandle returns the handle of the user who owns the post, for
// building a deep-link. Empty string (no error) when the post is gone.
func (r *NotificationRepo) PostAuthorHandle(ctx context.Context, postID string) (string, error) {
var h string
err := r.pg.Pool.QueryRow(ctx,
`SELECT u.handle FROM posts p JOIN users u ON u.id = p.user_id WHERE p.id = $1`,
postID).Scan(&h)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("post author handle: %w", err)
}
return h, nil
}
// CountUnread returns how many of a recipient's notifications are unread,
// excluding kinds the user has turned off for the in-app channel (matching
// ListByUser, so the badge never counts rows the feed won't show).
func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) {
var n int
err := r.pg.Pool.QueryRow(ctx,
`SELECT count(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL`, userID,
err := r.pg.Pool.QueryRow(ctx, `
SELECT count(*) FROM notifications n
WHERE n.user_id = $1 AND n.read_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM notification_prefs np
WHERE np.user_id = n.user_id AND np.kind = n.kind
AND np.channel = 'in_app' AND np.enabled = FALSE)`, userID,
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count unread notifications: %w", err)
+13 -13
View File
@@ -17,22 +17,22 @@ func NewStatsRepo(pg *Postgres) *StatsRepo { return &StatsRepo{pg: pg} }
// StatsSnapshot bundles totals, last-7-days counts, and feedback
// category breakdowns. One value rendered by /stats per request.
type StatsSnapshot struct {
Users int
UsersLast7Days int
Posts int
PostsLast7Days int
Collections int
PublicCollections int
Bookmarks int
Reshares int
Feedback int
FeedbackLast7Days int
FeedbackByCategory []CategoryCount
Users int `json:"users"`
UsersLast7Days int `json:"users_last_7_days"`
Posts int `json:"posts"`
PostsLast7Days int `json:"posts_last_7_days"`
Collections int `json:"collections"`
PublicCollections int `json:"public_collections"`
Bookmarks int `json:"bookmarks"`
Reshares int `json:"reshares"`
Feedback int `json:"feedback"`
FeedbackLast7Days int `json:"feedback_last_7_days"`
FeedbackByCategory []CategoryCount `json:"feedback_by_category"`
}
type CategoryCount struct {
Category string
Count int
Category string `json:"category"`
Count int `json:"count"`
}
// Snapshot runs every count needed by /stats. Queries run in series —
+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 {
+131 -9
View File
@@ -36,7 +36,9 @@ func NewUserRepo(pg *Postgres) *UserRepo {
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at, country,
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
preferred_chanting_time, peak_active_time`
preferred_chanting_time, peak_active_time, banned_at,
bookmark_limit, public_collection_limit, private_collection_limit,
disciple_id, bio`
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
// scanUser works for single-row and iterated reads alike.
@@ -50,7 +52,9 @@ func scanUser(row rowScanner, u *model.User) error {
&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
&u.PreferredChantingTime, &u.PeakActiveTime,
&u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt,
&u.BookmarkLimit, &u.PublicCollectionLimit, &u.PrivateCollectionLimit,
&u.DiscipleID, &u.Bio,
)
}
@@ -62,6 +66,7 @@ type ProfileUpdate struct {
Name *string
AvatarURL *string
Handle *string
Bio *string // nil=leave, ""=clear, text=set
Country *string
Sampradaya *string // nil=leave, ""=clear, code=set
Gender *string
@@ -228,6 +233,7 @@ func (r *UserRepo) UpdateProfile(ctx context.Context, id string, p ProfileUpdate
set("handle", *p.Handle)
}
nullableString("avatar_url", p.AvatarURL)
nullableString("bio", p.Bio)
nullableString("country", p.Country)
nullableString("sampradaya", p.Sampradaya)
nullableString("gender", p.Gender)
@@ -321,17 +327,35 @@ func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error
// Postgres ILIKE is fine at our user-table scale (low six figures
// max). When the table grows enough to warrant fuzzy / trigram
// matching, add a pg_trgm GIN index and switch to similarity().
func (r *UserRepo) Search(ctx context.Context, q string, limit int) ([]model.User, error) {
func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]model.User, int, error) {
cleaned := strings.TrimPrefix(strings.TrimSpace(q), "@")
if cleaned == "" {
return []model.User{}, nil
return []model.User{}, 0, nil
}
if limit <= 0 || limit > 50 {
limit = 20
}
if offset < 0 {
offset = 0
}
substring := "%" + cleaned + "%"
prefix := cleaned + "%"
// Total matches for pagination — same WHERE as the page query. Cheap
// enough at our user-table scale; revisit alongside the pg_trgm switch.
var total int
if err := r.pg.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM users WHERE name ILIKE $1 OR handle::text ILIKE $1`,
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
@@ -341,19 +365,117 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit int) ([]model.Use
WHEN name ILIKE $2 THEN 1
ELSE 2
END,
name
LIMIT $3`
name,
handle
LIMIT $3 OFFSET $4`
rows, err := r.pg.Pool.Query(ctx, sql, substring, prefix, limit)
rows, err := r.pg.Pool.Query(ctx, sql, substring, prefix, limit, offset)
if err != nil {
return nil, fmt.Errorf("search users: %w", err)
return nil, 0, fmt.Errorf("search users: %w", err)
}
defer rows.Close()
out := []model.User{}
for rows.Next() {
var u model.User
if err := scanUser(rows, &u); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
return nil, 0, fmt.Errorf("scan user: %w", err)
}
out = append(out, u)
}
return out, total, rows.Err()
}
// AllHandles returns every user's handle, for the public-profile sitemap.
// Projection-only (handle is unique + indexed), cheap to sweep. Excludes the
// reserved "me" handle (the signed-in user's own private library route).
func (r *UserRepo) AllHandles(ctx context.Context) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx,
`SELECT handle FROM users WHERE handle <> '' AND handle::text <> 'me' ORDER BY handle`)
if err != nil {
return nil, fmt.Errorf("list handles: %w", err)
}
defer rows.Close()
handles := []string{}
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
return nil, fmt.Errorf("scan handle: %w", err)
}
handles = append(handles, h)
}
return handles, rows.Err()
}
// DiscipleLink pairs a Mongo disciple id with the platform account an admin
// has linked to it. Powers the /disciples browser highlight: the reader page
// fetches the whole (small) set once and marks the matching cards.
type DiscipleLink struct {
DiscipleID string `json:"disciple_id"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL *string `json:"avatar_url"`
}
// ListDiscipleLinks returns every user→disciple link. Deliberately unpaged:
// only Śrīla Prabhupāda's own disciples are ever linked, so this is dozens of
// rows at most and the partial index on disciple_id keeps it a cheap scan.
// Suspended accounts are excluded so a banned user can't keep a public badge.
func (r *UserRepo) ListDiscipleLinks(ctx context.Context) ([]DiscipleLink, error) {
rows, err := r.pg.Pool.Query(ctx, `
SELECT disciple_id, handle, name, avatar_url
FROM users
WHERE disciple_id IS NOT NULL AND banned_at IS NULL
ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("list disciple links: %w", err)
}
defer rows.Close()
out := []DiscipleLink{}
for rows.Next() {
var l DiscipleLink
if err := rows.Scan(&l.DiscipleID, &l.Handle, &l.Name, &l.AvatarURL); err != nil {
return nil, fmt.Errorf("scan disciple link: %w", err)
}
out = append(out, l)
}
return out, rows.Err()
}
// RecentlyJoined returns up to `limit` users who registered on/after `since`,
// newest first, as a discovery list for the feed's "new on the platform"
// strip. Excludes the viewer, anyone the viewer already has a follow edge with
// (pending or accepted), and anyone involved in a block either direction — so
// every suggestion is a fresh account the viewer can actually follow.
func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since time.Time, limit int) ([]model.UserSummary, error) {
if limit <= 0 || limit > 20 {
limit = 5
}
const q = `
SELECT u.id, u.handle, u.name, u.avatar_url
FROM users u
WHERE u.created_at >= $2
AND u.id <> $1
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)
}
+182
View File
@@ -0,0 +1,182 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/prabhupadasays/spsays-bff/internal/model"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// VaniRepo backs "Vāṇī of the Day": the daily pick lives in Postgres
// (vani_of_the_day) while the candidate content is sampled from Mongo (the
// verses / prose_chapters / transcriptions collections).
type VaniRepo struct {
pg *Postgres
mongo *MongoDB
}
func NewVaniRepo(pg *Postgres, m *MongoDB) *VaniRepo {
return &VaniRepo{pg: pg, mongo: m}
}
// --- Postgres: the per-day record ---
// GetForDay returns the pick stored for the given UTC day, or (nil, nil) when
// nothing has been chosen yet.
func (r *VaniRepo) GetForDay(ctx context.Context, day time.Time) (*model.VaniPick, error) {
var kind, refID string
var payload []byte
err := r.pg.Pool.QueryRow(ctx,
`SELECT kind, ref_id, payload FROM vani_of_the_day WHERE day = $1`, day,
).Scan(&kind, &refID, &payload)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get vani for day: %w", err)
}
var p model.VaniPayload
if err := json.Unmarshal(payload, &p); err != nil {
return nil, fmt.Errorf("decode vani payload: %w", err)
}
return &model.VaniPick{Day: day, Kind: kind, RefID: refID, Payload: p}, nil
}
// RecentRefIDs returns the ref_ids chosen on or after `since` — the 90-day
// exclusion set for the picker.
func (r *VaniRepo) RecentRefIDs(ctx context.Context, since time.Time) ([]string, error) {
rows, err := r.pg.Pool.Query(ctx,
`SELECT ref_id FROM vani_of_the_day WHERE day >= $1`, since)
if err != nil {
return nil, fmt.Errorf("recent vani ref ids: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// Insert stores the day's pick, ignoring a race where another request already
// wrote today's (ON CONFLICT (day) DO NOTHING). Callers re-read GetForDay to
// get the authoritative row.
func (r *VaniRepo) Insert(ctx context.Context, pick *model.VaniPick) error {
b, err := json.Marshal(pick.Payload)
if err != nil {
return err
}
_, err = r.pg.Pool.Exec(ctx,
`INSERT INTO vani_of_the_day (day, kind, ref_id, payload)
VALUES ($1, $2, $3, $4::jsonb)
ON CONFLICT (day) DO NOTHING`,
pick.Day, pick.Kind, pick.RefID, b)
if err != nil {
return fmt.Errorf("insert vani pick: %w", err)
}
return nil
}
// BookTitle resolves a book code to its display title (from book_meta.data.title),
// or "" if unknown. Used to label a prose pick with its book name. Decoding into
// a typed struct avoids the bson.M-nested-doc pitfall (nested documents decode
// as bson.D, so a `raw["data"].(bson.M)` assertion would silently fail).
func (r *VaniRepo) BookTitle(ctx context.Context, book string) string {
var doc struct {
Data struct {
Title string `bson:"title"`
} `bson:"data"`
}
if err := r.mongo.Collection("book_meta").
FindOne(ctx, bson.D{{Key: "book", Value: book}}).Decode(&doc); err != nil {
return ""
}
return doc.Data.Title
}
// --- Mongo: random candidate sampling ---
// excludeIDs turns hex _id strings into a $nin ObjectID array.
func excludeIDs(hexes []string) bson.A {
arr := bson.A{}
for _, h := range hexes {
if oid, err := bson.ObjectIDFromHex(h); err == nil {
arr = append(arr, oid)
}
}
return arr
}
func (r *VaniRepo) sampleOne(ctx context.Context, coll string, match bson.D, out interface{}) (bool, error) {
cur, err := r.mongo.Collection(coll).Aggregate(ctx, mongo.Pipeline{
bson.D{{Key: "$match", Value: match}},
bson.D{{Key: "$sample", Value: bson.D{{Key: "size", Value: 1}}}},
})
if err != nil {
return false, fmt.Errorf("sample %s: %w", coll, err)
}
defer cur.Close(ctx)
if cur.Next(ctx) {
if err := cur.Decode(out); err != nil {
return false, err
}
return true, nil
}
return false, cur.Err()
}
// SampleVerse returns a random verse with a translation, excluding the given
// _id hexes. Returns nil when nothing matches.
func (r *VaniRepo) SampleVerse(ctx context.Context, excludeHex []string) (*VerseDoc, error) {
match := bson.D{
{Key: "_id", Value: bson.D{{Key: "$nin", Value: excludeIDs(excludeHex)}}},
{Key: "translation", Value: bson.D{{Key: "$exists", Value: true}, {Key: "$ne", Value: ""}}},
}
var d VerseDoc
ok, err := r.sampleOne(ctx, "verses", match, &d)
if err != nil || !ok {
return nil, err
}
return &d, nil
}
// SampleProse returns a random prose chapter with body text, excluding the
// given _id hexes.
func (r *VaniRepo) SampleProse(ctx context.Context, excludeHex []string) (*ProseChapterDoc, error) {
match := bson.D{
{Key: "_id", Value: bson.D{{Key: "$nin", Value: excludeIDs(excludeHex)}}},
{Key: "paragraphs.0", Value: bson.D{{Key: "$exists", Value: true}}},
}
var d ProseChapterDoc
ok, err := r.sampleOne(ctx, "prose_chapters", match, &d)
if err != nil || !ok {
return nil, err
}
return &d, nil
}
// SampleTranscript returns a random published transcript with content,
// excluding the given _id hexes.
func (r *VaniRepo) SampleTranscript(ctx context.Context, excludeHex []string) (*TranscriptionDoc, error) {
match := bson.D{
{Key: "_id", Value: bson.D{{Key: "$nin", Value: excludeIDs(excludeHex)}}},
{Key: "isPublished", Value: true},
{Key: "content.0", Value: bson.D{{Key: "$exists", Value: true}}},
}
var d TranscriptionDoc
ok, err := r.sampleOne(ctx, "transcriptions", match, &d)
if err != nil || !ok {
return nil, err
}
return &d, nil
}
+347 -27
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"path/filepath"
"time"
"github.com/go-chi/chi/v5"
chiMiddleware "github.com/go-chi/chi/v5/middleware"
@@ -18,6 +19,8 @@ import (
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
"github.com/prabhupadasays/spsays-bff/pkg/response"
"github.com/prabhupadasays/spsays-bff/pkg/revalidate"
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
"go.uber.org/zap"
)
@@ -31,6 +34,11 @@ type Server struct {
pg *repository.Postgres
redis *repository.Redis
// discipleRepo is held on the struct (unlike the other Mongo repos, which
// stay local to New) because initAuth re-builds discipleH with the
// Postgres userRepo once it exists — see the direct-disciple link feature.
discipleRepo *repository.DiscipleRepo
// Worker lifecycle
workerCancel context.CancelFunc
workerCtx context.Context // derived in initShortener; cancelled on Close
@@ -46,21 +54,29 @@ type Server struct {
otherDocH *handler.OtherDocumentHandler
transcriptionH *handler.TranscriptionHandler
searchH *handler.SearchHandler
vaniH *handler.VaniHandler
sitemapH *handler.SitemapHandler
shortURLH *handler.ShortURLHandler
authH *handler.AuthHandler
oauthH *handler.OAuthHandler
userH *handler.UserHandler
audioH *handler.AudioProgressHandler
postH *handler.PostHandler
commentH *handler.CommentHandler
bookmarkH *handler.BookmarkHandler
collectionH *handler.CollectionHandler
profileH *handler.ProfileHandler
relationH *handler.RelationshipHandler
soketiWebhookH *handler.SoketiWebhookHandler
broadcastAuthH *handler.BroadcastingAuthHandler
chatH *handler.ChatHandler
notificationH *handler.NotificationHandler
sadhnaH *handler.SadhnaHandler
pushH *handler.PushHandler
reminderH *handler.ReminderHandler
feedbackH *handler.FeedbackHandler
statsH *handler.StatsHandler
adminH *handler.AdminHandler
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
authIssuer *auth.Issuer
@@ -87,23 +103,28 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
magazineRepo := repository.NewMagazineRepo(mongoDB)
otherDocRepo := repository.NewOtherDocumentRepo(mongoDB)
transcriptionRepo := repository.NewTranscriptionRepo(mongoDB)
sitemapRepo := repository.NewSitemapRepo(mongoDB)
s := &Server{
cfg: cfg,
logger: logger,
mongo: mongoDB,
discipleRepo: discipleRepo,
// Initialize handlers
bookH: handler.NewBookHandler(bookRepo),
letterH: handler.NewLetterHandler(letterRepo),
essayH: handler.NewEssayHandler(essayRepo),
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
discipleH: handler.NewDiscipleHandler(discipleRepo),
bookH: handler.NewBookHandler(bookRepo),
letterH: handler.NewLetterHandler(letterRepo),
essayH: handler.NewEssayHandler(essayRepo),
glossaryH: handler.NewGlossaryHandler(glossaryRepo),
// userRepo half is attached later in initAuth (Postgres isn't up yet).
discipleH: handler.NewDiscipleHandler(discipleRepo, nil),
journalH: handler.NewJournalHandler(journalRepo),
magazineH: handler.NewMagazineHandler(magazineRepo),
otherDocH: handler.NewOtherDocumentHandler(otherDocRepo),
transcriptionH: handler.NewTranscriptionHandler(transcriptionRepo),
searchH: handler.NewSearchHandler(tsClient),
sitemapH: handler.NewSitemapHandler(sitemapRepo),
}
// URL shortener: Postgres (canonical) + Redis (queue + hot cache) + background workers.
@@ -138,6 +159,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
SecretAccessKey: cfg.R2SecretAccessKey,
Bucket: cfg.R2Bucket,
PublicURL: cfg.R2PublicURL,
PrivateBucket: cfg.R2PrivateBucket,
})
if err != nil {
logger.Warn("auth: R2 unavailable — avatar uploads will return 503", zap.Error(err))
@@ -156,6 +178,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
}, logger)
authSvc := service.NewAuthService(userRepo, tokenRepo, oauthRepo, issuer, mail, logger, cfg.FrontendURL)
authSvc.SetRevalidator(revalidate.New(cfg.FrontendURL, cfg.RevalidateSecret, logger))
userSvc := service.NewUserService(userRepo, r2)
// Social repositories + services share the same Postgres pool and R2 client.
@@ -165,7 +188,15 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// Likes: membership in Postgres, count delta batched through Redis
// (s.redis may be nil — LikeRepo then applies counts directly).
likeRepo := repository.NewLikeRepo(s.pg, s.redis, logger)
postSvc := service.NewPostService(postRepo, r2, likeRepo)
mentionRepo := repository.NewMentionRepo(s.pg)
commentRepo := repository.NewCommentRepo(s.pg, logger)
// Per-user "already served" set so the home feed never repeats posts
// across pages (nil-safe — falls back to offset paging without Redis).
feedRepo := repository.NewFeedRepo(s.redis, logger)
postSvc := service.NewPostService(postRepo, r2, likeRepo, mentionRepo, commentRepo, feedRepo)
// Soketi signs webhooks with the app secret; on channel_vacated for a
// per-user notifications channel we evict that user's feed seen-set.
s.soketiWebhookH = handler.NewSoketiWebhookHandler(cfg.SoketiAppKey, cfg.SoketiAppSecret, feedRepo, logger)
bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo)
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
@@ -176,6 +207,25 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
s.authH = handler.NewAuthHandler(authSvc, cookieOpts)
s.userH = handler.NewUserHandler(userSvc)
// Re-wire the disciple handler now that Postgres-backed users exist: its
// /disciples/linked route joins the Mongo disciple records to the platform
// accounts an admin has linked to them. Built in New() with a nil userRepo
// (Mongo is up before Postgres), so this is where it gets the other half.
if s.discipleRepo != nil {
s.discipleH = handler.NewDiscipleHandler(s.discipleRepo, userRepo)
}
// Audio playback resume points — per-user, per-file last position that
// lets the floating player pick up where it left off across devices.
audioProgressRepo := repository.NewAudioProgressRepo(s.pg)
s.audioH = handler.NewAudioProgressHandler(service.NewAudioProgressService(audioProgressRepo))
// Vāṇī of the Day — one daily pick (prose/verse/transcript) for the home
// page, chosen from Mongo content and recorded in Postgres so it doesn't
// repeat within 90 days.
vaniRepo := repository.NewVaniRepo(s.pg, s.mongo)
s.vaniH = handler.NewVaniHandler(service.NewVaniService(vaniRepo, logger))
// OAuth — wired only when at least one provider is configured. The
// registry skips providers with missing creds, so partial configs (e.g.
// Google only, no Facebook) work out of the box.
@@ -209,19 +259,39 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
cfg.SoketiAppID, cfg.SoketiAppKey, cfg.SoketiAppSecret,
cfg.SoketiHost, cfg.SoketiPort, cfg.SoketiUseTLS, logger,
)
// Chat — DMs, groups and the (still coming-soon) Kautilya threads.
chatRepo := repository.NewChatRepo(s.pg)
chatSvc := service.NewChatService(chatRepo, blockRepo, rtClient, r2, s.redis, logger)
s.chatH = handler.NewChatHandler(chatSvc)
// Channel authorization for private-* subscriptions. Rules are explicit
// and default-deny — an unlisted channel is refused.
s.broadcastAuthH = handler.NewBroadcastingAuthHandler(rtClient, logger,
handler.NotificationsRule(),
handler.ConversationRule(chatSvc.IsParticipant),
)
pushRepo := repository.NewPushRepo(s.pg)
pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger)
s.pushH = handler.NewPushHandler(pushSender, pushRepo)
notifSvc := service.NewNotificationService(notifRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL)
notifPrefsRepo := repository.NewNotificationPrefsRepo(s.pg)
notifSvc := service.NewNotificationService(notifRepo, notifPrefsRepo, userRepo, mail, rtClient, pushSender, logger, cfg.FrontendURL)
// Wire the notification seam into the post service so a new like notifies
// the author (push + realtime; not the liker themselves).
postSvc.SetNotifier(notifSvc)
// Comments: comment / reply / like / pin / report over the same Postgres
// pool, with the notification seam so new comments notify the post author
// and replies notify the comment they answer.
commentSvc := service.NewCommentService(commentRepo)
commentSvc.SetNotifier(notifSvc)
s.commentH = handler.NewCommentHandler(commentSvc)
// Sādhana reminders: a background worker nudges users (email + push +
// websocket) to chant / post a quote at their local time. Shares the
// notification seam above so all three channels fire.
reminderRepo := repository.NewReminderRepo(s.pg)
reminderSvc := service.NewReminderService(reminderRepo, notifSvc, logger, cfg.FrontendURL)
reminderSvc := service.NewReminderService(reminderRepo, notifPrefsRepo, userRepo, notifSvc, logger, cfg.FrontendURL)
s.reminderH = handler.NewReminderHandler(reminderSvc)
wctx := s.workerCtx
if wctx == nil {
@@ -233,11 +303,11 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
// every 15 minutes (no-op when Redis is unavailable).
worker.NewLikesWorker(likeRepo, logger).Run(wctx)
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
graphSvc := service.NewSocialGraphService(followRepo, blockRepo, reportRepo, postRepo, userRepo, chatRepo, notifSvc, mail, cfg.FeedbackAdminEmails, logger)
s.relationH = handler.NewRelationshipHandler(graphSvc)
s.notificationH = handler.NewNotificationHandler(notifSvc)
s.postH = handler.NewPostHandler(postSvc, graphSvc)
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc, likeRepo)
s.profileH = handler.NewProfileHandler(userRepo, postRepo, collectionRepo, graphSvc, likeRepo, mentionRepo, commentRepo)
// Sādhana streak: daily chanting log + the growing-plant state. Reads
// the chanting half from sadhna_logs and the posting half from the
@@ -258,7 +328,14 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
statsRepo := repository.NewStatsRepo(s.pg)
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo, feedbackSvc)
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo)
// Admin panel — read-mostly listing + notify/reply/resolve actions.
// Reuses the notification service (delivery) and mailer (feedback reply).
// Gated by RequireAuth + RequireAdmin in the router.
adminRepo := repository.NewAdminRepo(s.pg)
adminSvc := service.NewAdminService(adminRepo, statsRepo, notifSvc, feedbackSvc, userRepo, s.discipleRepo, mail, logger, cfg.FrontendURL)
s.adminH = handler.NewAdminHandler(adminSvc)
}
// initShortener wires Postgres + Redis + the shorturl handler/workers.
@@ -324,6 +401,29 @@ func (s *Server) Close() {
s.logger.Info("all connections closed")
}
// sitemapUsers — GET /api/v1/sitemap/users → { "urls": ["/u/handle", …] }.
// Public-profile sitemap. Reads userRepo at request time (it's wired only
// after Postgres init); degrades to an empty list when Postgres is down.
func (s *Server) sitemapUsers(w http.ResponseWriter, r *http.Request) {
if s.userRepo == nil {
response.JSON(w, http.StatusOK, map[string]any{"urls": []string{}})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
handles, err := s.userRepo.AllHandles(ctx)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not build user sitemap")
return
}
urls := make([]string, 0, len(handles))
for _, h := range handles {
urls = append(urls, "/u/"+h)
}
response.JSON(w, http.StatusOK, map[string]any{"urls": urls})
}
// authUnavailable is the 503 fallback used when Postgres is down and the
// auth handlers couldn't be wired up. Keeps every /auth/* and /users/* URL
// returning a structured error rather than 404.
@@ -351,14 +451,25 @@ func (s *Server) setupRouter() {
// Global middleware
r.Use(chiMiddleware.RequestID)
r.Use(chiMiddleware.RealIP)
// Our RealIP (not chi's): a single trusted edge proxy (Dokploy's Traefik)
// appends the real client to the right of X-Forwarded-For, so we read the
// right-most entry. chi's RealIP trusts the spoofable left-most entry.
r.Use(middleware.RealIP)
r.Use(middleware.Logger(s.logger))
r.Use(chiMiddleware.Recoverer)
r.Use(chiMiddleware.Compress(5))
// CORS
// CORS — the main frontend plus, when configured, the admin panel (a
// separate origin). Empty origins are filtered so the list never widens
// unintentionally.
allowedOrigins := []string{}
for _, o := range []string{s.cfg.FrontendURL, s.cfg.AdminURL} {
if o != "" {
allowedOrigins = append(allowedOrigins, o)
}
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{s.cfg.FrontendURL},
AllowedOrigins: allowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
ExposedHeaders: []string{"X-Request-ID"},
@@ -377,36 +488,46 @@ func (s *Server) setupRouter() {
// Currently unauthenticated by product decision; gate here if that
// ever needs to change.
if s.statsH != nil {
// Read-only public snapshot. The feedback acknowledge/resolve actions
// moved to the admin panel (POST /api/v1/admin/feedback/{id}/...) — they
// must not be exposed on this unauthenticated page.
r.Get("/stats", s.statsH.Render)
// One-way per-row feedback actions — POST so a refresh of /stats
// can't replay them. Both live at the same public access level as
// /stats itself (gate them together if that ever changes).
r.Post("/stats/feedback/{id}/acknowledge", s.statsH.AcknowledgeFeedback)
r.Post("/stats/feedback/{id}/resolve", s.statsH.ResolveFeedback)
}
// API v1
r.Route("/api/v1", func(r chi.Router) {
// Sitemap — flat list of public content URLs (consumed by the
// frontend's daily-cached sitemap routes). Public, read-only.
r.Get("/sitemap/content", s.sitemapH.GetContent)
// Public user profiles (Postgres) — separate from Mongo content.
r.Get("/sitemap/users", s.sitemapUsers)
// Books
r.Route("/books", func(r chi.Router) {
r.Get("/", s.bookH.ListBooks)
// --- Verse books with divisions ---
// SB — canto divisions
// SB — canto divisions (plus prose front-matter segments;
// the static /chapters routes take precedence over /{division}).
r.Route("/sb", func(r chi.Router) {
r.Use(injectBookCode("sb"))
r.Get("/", s.bookH.GetBook)
r.Get("/chapters", s.bookH.ListProseChapters)
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
r.Get("/{division}", s.bookH.GetDivisionInfo)
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
r.Get("/{division}/{chapter}/{verse}", s.bookH.GetDivisionVerse)
})
// CC — līlā divisions
// CC — līlā divisions (plus prose front-matter segments;
// the static /chapters routes take precedence over /{division}).
r.Route("/cc", func(r chi.Router) {
r.Use(injectBookCode("cc"))
r.Get("/", s.bookH.GetBook)
r.Get("/chapters", s.bookH.ListProseChapters)
r.Get("/chapters/{slug}", s.bookH.GetProseChapter)
r.Get("/{division}", s.bookH.GetDivisionInfo)
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
@@ -414,8 +535,8 @@ func (s *Server) setupRouter() {
})
// --- Verse books without divisions ---
// BG, SB1962: pure verse books
for _, code := range []string{"bg", "sb1962"} {
// SB1962: pure verse book
for _, code := range []string{"sb1962"} {
code := code // capture for closure
r.Route("/"+code, func(r chi.Router) {
r.Use(injectBookCode(code))
@@ -427,8 +548,9 @@ func (s *Server) setupRouter() {
}
// --- Verse + prose intro books (no division) ---
// BS, MM, NBS: have both verses and prose intro segments
for _, code := range []string{"bs", "mm", "nbs"} {
// BG, BS, MM, NBS: verses plus prose front-matter segments
// (the static /chapters routes take precedence over /{chapter}).
for _, code := range []string{"bg", "bs", "mm", "nbs"} {
code := code
r.Route("/"+code, func(r chi.Router) {
r.Use(injectBookCode(code))
@@ -519,6 +641,9 @@ func (s *Server) setupRouter() {
r.Get("/letters", s.discipleH.LetterCounts)
r.Get("/suffixes", s.discipleH.SuffixCounts)
r.Get("/search", s.discipleH.Search)
// Disciples with a platform account (admin-linked) — drives the
// highlight in the /disciples browser.
r.Get("/linked", s.discipleH.Linked)
})
// Journals
@@ -551,9 +676,11 @@ func (s *Server) setupRouter() {
r.Get("/years", s.transcriptionH.GetYears)
r.Get("/places", s.transcriptionH.GetPlaces)
r.Get("/types", s.transcriptionH.GetTypeDistribution)
r.Get("/books", s.transcriptionH.GetBooks)
r.Get("/year/{year}", s.transcriptionH.GetByYear)
r.Get("/place/{place}", s.transcriptionH.GetByPlace)
r.Get("/type/{type}", s.transcriptionH.GetByType)
r.Get("/book/{book}", s.transcriptionH.GetByBook)
r.Get("/adjacent/{slug}", s.transcriptionH.GetAdjacent)
r.Get("/{slug}", s.transcriptionH.GetBySlug)
})
@@ -561,6 +688,9 @@ func (s *Server) setupRouter() {
// Search (Typesense)
r.Get("/search", s.searchH.Search)
// Vāṇī of the Day — public daily content pick for the home page.
r.Get("/vani/today", s.vaniH.Today)
// --- Auth ---
// Public endpoints (no JWT required) for sign-up, log-in, password reset,
// and email verification. The /auth/me + /auth/resend-verification routes
@@ -626,6 +756,13 @@ func (s *Server) setupRouter() {
// opaque JSON object.
r.Get("/me/reading-prefs", s.userH.GetReadingPrefs)
r.Put("/me/reading-prefs", s.userH.UpdateReadingPrefs)
// Audio player resume points roam with the account too, so a
// lecture picks up where you left off on any device. Schema is
// owned by the frontend; the server stores opaque key/position
// rows and merges newest-wins.
r.Get("/me/audio-progress", s.audioH.Get)
r.Put("/me/audio-progress", s.audioH.Put)
r.Delete("/me/audio-progress", s.audioH.Delete)
})
})
@@ -643,6 +780,12 @@ func (s *Server) setupRouter() {
r.Get("/{id}", s.postH.Get)
// Paginated "liked by" list — public, like the count itself.
r.Get("/{id}/likes", s.postH.ListLikes)
// Tagged users on a post — public (read-only for non-authors).
r.Get("/{id}/mentions", s.postH.ListMentions)
// Comments — public read (viewer_has_liked populated when signed in).
if s.commentH != nil {
r.Get("/{id}/comments", s.commentH.List)
}
})
// Protected — create / delete / image presign / "my posts" /
// reshare actions.
@@ -650,8 +793,12 @@ func (s *Server) setupRouter() {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Post("/", s.postH.Create)
r.Delete("/{id}", s.postH.Delete)
// Hide / unhide from timelines + public collections (owner-only).
r.Post("/{id}/hide", s.postH.Hide)
r.Delete("/{id}/hide", s.postH.Unhide)
r.Put("/{id}/images", s.postH.ReplaceImages)
r.Get("/mine", s.postH.ListMine)
r.Get("/hidden", s.postH.ListHidden)
r.Post("/image-presign", s.postH.ImagePresign)
// Reshare is auth-required: anonymous users can't claim
// to have reshared anything. The action is idempotent so
@@ -662,8 +809,43 @@ func (s *Server) setupRouter() {
// count is batched into likes_count via Redis.
r.Post("/{id}/like", s.postH.Like)
r.Delete("/{id}/like", s.postH.Unlike)
// Mentions: composer suggestions + author add/remove of tags.
r.Get("/mention-suggest", s.postH.MentionSuggest)
r.Post("/{id}/mentions", s.postH.AddMention)
r.Delete("/{id}/mentions/{userID}", s.postH.RemoveMention)
// Report a post (category + optional reason) → admin email.
r.Post("/{id}/report", s.postH.Report)
// Add a comment / reply (anyone signed in).
if s.commentH != nil {
r.Post("/{id}/comments", s.commentH.Create)
}
})
})
// --- Social: comments (replies, likes, pin, delete, report) ---
// Keyed by comment id; the post-scoped list/create live under /posts.
r.Route("/comments", func(r chi.Router) {
if s.commentH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
// Public read — a thread's replies (viewer_has_liked when signed in).
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/{id}/replies", s.commentH.Replies)
})
// Protected — like / pin / delete / report.
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Post("/{id}/like", s.commentH.Like)
r.Delete("/{id}/like", s.commentH.Unlike)
// Pin / unpin — post author only (enforced in the service).
r.Post("/{id}/pin", s.commentH.Pin)
r.Delete("/{id}/pin", s.commentH.Unpin)
// Delete — comment author or post author; removes the thread.
r.Delete("/{id}", s.commentH.Delete)
// Report a comment (category + optional reason).
r.Post("/{id}/report", s.commentH.Report)
})
})
@@ -713,6 +895,88 @@ func (s *Server) setupRouter() {
// present, so the feed can carry `viewer_has_reshared` on each
// post. Without a cookie the routes still respond — they just
// return the anonymous shape (all-false).
// Soketi webhooks — server-to-server, authenticated by the HMAC
// signature (not a session). channel_vacated on a per-user
// notifications channel evicts that user's feed seen-set.
if s.soketiWebhookH != nil {
r.Post("/webhooks/soketi", s.soketiWebhookH.Handle)
}
// Chat — inbox, threads, messages. Every route requires a session;
// membership is re-checked per request in the service, so a
// conversation id on its own grants nothing.
if s.chatH != nil {
r.Route("/chat", func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/conversations", s.chatH.Inbox)
r.Post("/conversations/dm", s.chatH.OpenDM)
r.Post("/conversations/group", s.chatH.CreateGroup)
r.Post("/conversations/kautilya", s.chatH.OpenAIThread)
r.Post("/link-preview", s.chatH.LinkPreview)
r.Get("/settings", s.chatH.Settings)
r.Patch("/settings", s.chatH.UpdateSettings)
r.Get("/attachments/{attachmentID}", s.chatH.Attachment)
r.Route("/conversations/{id}", func(r chi.Router) {
r.Get("/", s.chatH.Detail)
r.Patch("/", s.chatH.UpdateGroup)
r.Post("/members", s.chatH.AddMembers)
r.Delete("/members/{handle}", s.chatH.RemoveMember)
r.Patch("/members/{handle}", s.chatH.SetMemberRole)
r.Get("/messages", s.chatH.Messages)
r.Post("/messages", s.chatH.Send)
r.Post("/read", s.chatH.MarkRead)
r.Post("/delivered", s.chatH.MarkDelivered)
r.Get("/receipts", s.chatH.Receipts)
r.Post("/mute", s.chatH.SetMute)
r.Post("/attachments/presign", s.chatH.PresignAttachment)
r.Post("/avatar/presign", s.chatH.PresignGroupAvatar)
r.Get("/avatar", s.chatH.GroupAvatar)
r.Get("/gallery", s.chatH.Gallery)
r.Get("/starred", s.chatH.Starred)
r.Post("/messages/{messageID}/star", s.chatH.Star)
r.Delete("/messages/{messageID}/star", s.chatH.Unstar)
r.Delete("/messages/{messageID}", s.chatH.DeleteMessage)
r.Post("/messages/{messageID}/report", s.chatH.ReportMessage)
r.Post("/forward", s.chatH.Forward)
})
})
}
// Pusher channel authorization — pusher-js posts here (as its
// `authEndpoint`) before subscribing to a private-/presence- channel.
// Session-authenticated: the signature is only minted for a caller
// who has proven who they are and passed the channel's rule.
if s.broadcastAuthH != nil {
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Post("/broadcasting/auth", s.broadcastAuthH.Handle)
})
}
// Home feed — the global, personalised feed of posts + its "new on
// the platform" discovery strip. Auth-required (the feed is
// per-viewer: ordering + relationship flags depend on the caller).
r.Route("/feed", func(r chi.Router) {
if s.postH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/", s.postH.Feed)
r.Get("/new-users", s.postH.FeedNewUsers)
// Clear the per-user seen-set (logout / page unload).
r.Post("/reset", s.postH.FeedReset)
})
})
// Caller's pending incoming follow requests (the notifications
// "Friend Requests" tab). Caller-scoped, so it lives outside /u/{handle}.
if s.profileH != nil {
r.With(middleware.RequireAuth(s.authIssuer, s.userRepo)).
Get("/follow-requests", s.profileH.IncomingFollowRequests)
}
r.Route("/u", func(r chi.Router) {
if s.profileH == nil {
r.HandleFunc("/*", s.authUnavailable)
@@ -725,6 +989,7 @@ func (s *Server) setupRouter() {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Get("/{handle}", s.profileH.Get)
r.Get("/{handle}/feed", s.profileH.Feed)
r.Get("/{handle}/mentions", s.profileH.MentionsFeed)
r.Get("/{handle}/followers", s.profileH.Followers)
r.Get("/{handle}/following", s.profileH.Following)
})
@@ -773,10 +1038,20 @@ func (s *Server) setupRouter() {
r.Group(func(r chi.Router) {
if s.feedbackH == nil {
r.HandleFunc("/feedback", s.authUnavailable)
r.HandleFunc("/feedback/mine", s.authUnavailable)
return
}
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Post("/feedback", s.feedbackH.Submit)
// Submit — OptionalAuth (anonymous callers allowed).
r.Group(func(r chi.Router) {
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
r.Post("/feedback", s.feedbackH.Submit)
})
// My submissions — RequireAuth. Lists the caller's own rows
// with status + admin reply for the /settings/feedbacks page.
r.Group(func(r chi.Router) {
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Get("/feedback/mine", s.feedbackH.Mine)
})
})
// Notifications — the signed-in user's in-app feed + unread badge.
@@ -791,6 +1066,12 @@ func (s *Server) setupRouter() {
r.Get("/", s.notificationH.List)
r.Get("/unread-count", s.notificationH.UnreadCount)
r.Post("/read", s.notificationH.MarkAllRead)
r.Get("/prefs", s.notificationH.GetPrefs)
r.Put("/prefs", s.notificationH.UpdatePrefs)
// Legacy single-toggle endpoints — shims over /prefs, kept until
// old clients are gone.
r.Get("/email-prefs", s.notificationH.GetEmailPrefs)
r.Put("/email-prefs", s.notificationH.UpdateEmailPrefs)
})
// --- Web Push --- VAPID key + per-user subscription management.
@@ -824,6 +1105,45 @@ func (s *Server) setupRouter() {
r.Post("/timezone", s.reminderH.SetTimezone)
}
})
// --- Admin panel --- every route gated by RequireAuth + RequireAdmin
// (role == "admin"), so the whole surface is invisible to ordinary
// accounts. Additive: no existing route or payload changes.
r.Route("/admin", func(r chi.Router) {
if s.adminH == nil {
r.HandleFunc("/*", s.authUnavailable)
return
}
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
r.Use(middleware.RequireAdmin(s.userRepo))
r.Get("/stats", s.adminH.Stats)
r.Get("/users", s.adminH.ListUsers)
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
r.Post("/users/{id}/ban", s.adminH.BanUser)
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
r.Post("/users/{id}/limits", s.adminH.SetUserLimits)
// Direct-disciple linking: mark an account as one of Śrīla
// Prabhupāda's initiated disciples (or clear the mark).
r.Get("/disciples", s.adminH.ListDisciples)
r.Post("/users/{id}/disciple", s.adminH.LinkDisciple)
r.Delete("/users/{id}/disciple", s.adminH.UnlinkDisciple)
r.Get("/posts", s.adminH.ListPosts)
r.Post("/posts/{id}/hide", s.adminH.HidePost)
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
r.Delete("/posts/{id}", s.adminH.DeletePost)
r.Post("/posts/{id}/restore", s.adminH.RestorePost)
r.Delete("/comments/{id}", s.adminH.DeleteComment)
r.Post("/comments/{id}/restore", s.adminH.RestoreComment)
r.Get("/collections", s.adminH.ListCollections)
r.Get("/feedback", s.adminH.ListFeedback)
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
r.Post("/feedback/{id}/acknowledge", s.adminH.AcknowledgeFeedback)
r.Post("/feedback/{id}/resolve", s.adminH.ResolveFeedback)
r.Get("/reports", s.adminH.ListReports)
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
r.Get("/short-urls", s.adminH.ListShortURLs)
r.Get("/short-urls/stats", s.adminH.ShortURLStats)
})
})
s.router = r
+446
View File
@@ -0,0 +1,446 @@
package service
import (
"bytes"
"context"
"errors"
"html/template"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"go.uber.org/zap"
)
// ErrAdminUserNotFound is returned by NotifyUser when the target id is unknown.
var ErrAdminUserNotFound = errors.New("user not found")
// AdminService backs the admin panel: read-mostly listing plus two actions
// (notify a user, reply to feedback) and report resolution. It reuses the
// existing NotificationService for delivery and the shared mailer for the
// feedback-reply email — no new infrastructure.
type AdminService struct {
repo *repository.AdminRepo
stats *repository.StatsRepo
notif *NotificationService
feedback *FeedbackService
// users / disciples back the direct-disciple linking feature: the link
// column lives in Postgres (users) while the disciple record lives in
// Mongo (disciples), so the service is where the two stores meet. Both
// may be nil in degraded boots (no Mongo) — the link methods guard.
users *repository.UserRepo
disciples *repository.DiscipleRepo
mail mailer.Mailer
logger *zap.Logger
frontendURL string
}
func NewAdminService(repo *repository.AdminRepo, stats *repository.StatsRepo, notif *NotificationService, feedback *FeedbackService, users *repository.UserRepo, disciples *repository.DiscipleRepo, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService {
return &AdminService{repo: repo, stats: stats, notif: notif, feedback: feedback, users: users, disciples: disciples, mail: mail, logger: logger, frontendURL: frontendURL}
}
// Stats returns the system snapshot (counts + 7-day deltas + feedback
// breakdown) — the same data the public /stats page renders, as JSON for the
// admin dashboard home.
func (s *AdminService) Stats(ctx context.Context) (*repository.StatsSnapshot, error) {
return s.stats.Snapshot(ctx)
}
// AcknowledgeFeedback marks a feedback row acknowledged and emails the author
// that we've seen it. Reuses FeedbackService so the email is identical to the
// (now removed) public /stats action. Returns ErrAlreadyAcknowledged when the
// row was already acknowledged.
func (s *AdminService) AcknowledgeFeedback(ctx context.Context, id string) error {
return s.feedback.Acknowledge(ctx, id)
}
// ResolveFeedback marks a feedback row resolved and emails the author a
// thank-you. Returns ErrAlreadyResolved when the row was already resolved.
func (s *AdminService) ResolveFeedback(ctx context.Context, id string) error {
return s.feedback.Resolve(ctx, id)
}
func (s *AdminService) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
users, total, err := s.repo.ListUsers(ctx, q, limit, offset)
if err != nil {
return nil, 0, err
}
s.hydrateDiscipleNames(ctx, users)
return users, total, nil
}
// hydrateDiscipleNames fills DiscipleName on any linked rows in the page with a
// single Mongo lookup. Best-effort by design: the panel is far more useful with
// an un-named link than with a 500, so a Mongo failure logs and leaves the
// names nil (the UI then shows the bare "linked" state).
func (s *AdminService) hydrateDiscipleNames(ctx context.Context, users []model.AdminUser) {
if s.disciples == nil {
return
}
ids := make([]string, 0, len(users))
for _, u := range users {
if u.DiscipleID != nil && *u.DiscipleID != "" {
ids = append(ids, *u.DiscipleID)
}
}
if len(ids) == 0 {
return
}
docs, err := s.disciples.FindByIDs(ctx, ids)
if err != nil {
s.logger.Warn("admin: hydrate disciple names failed", zap.Error(err))
return
}
byID := make(map[string]string, len(docs))
for _, d := range docs {
byID[d.ID.Hex()] = strings.TrimSpace(d.InitiatedName + " " + d.Suffix)
}
for i := range users {
if users[i].DiscipleID == nil {
continue
}
if name, ok := byID[*users[i].DiscipleID]; ok {
users[i].DiscipleName = &name
}
}
}
// ErrDiscipleNotFound is returned by LinkDisciple when the requested disciple
// id doesn't resolve to a document in Mongo.
var ErrDiscipleNotFound = errors.New("disciple not found")
// ErrDisciplesUnavailable is returned when the Mongo-backed disciple
// collection isn't wired (degraded boot) — linking can't be validated, so we
// refuse rather than write an unverified id.
var ErrDisciplesUnavailable = errors.New("disciple directory unavailable")
// SearchDisciples backs the admin panel's disciple picker. An empty query
// returns the first page alphabetically so the picker opens with content.
// Each option carries the handle of the account already linked to it, letting
// the UI disable those rows instead of surfacing a conflict on submit.
func (s *AdminService) SearchDisciples(ctx context.Context, q string, limit int) ([]model.AdminDisciple, error) {
if s.disciples == nil {
return nil, ErrDisciplesUnavailable
}
if limit <= 0 || limit > 100 {
limit = 25
}
docs, err := s.disciples.Search(ctx, strings.TrimSpace(q), limit)
if err != nil {
return nil, err
}
// Which disciples are already claimed, and by whom.
claimed := map[string]string{}
if s.users != nil {
links, err := s.users.ListDiscipleLinks(ctx)
if err != nil {
s.logger.Warn("admin: load disciple links failed", zap.Error(err))
} else {
for _, l := range links {
claimed[l.DiscipleID] = l.Handle
}
}
}
out := make([]model.AdminDisciple, 0, len(docs))
for _, d := range docs {
id := d.ID.Hex()
opt := model.AdminDisciple{
ID: id,
InitiatedName: d.InitiatedName,
Suffix: d.Suffix,
InitiationDate: d.InitiationDate,
InitiationPlace: d.InitiationPlace,
}
if h, ok := claimed[id]; ok {
handle := h
opt.LinkedHandle = &handle
}
out = append(out, opt)
}
return out, nil
}
// LinkDisciple marks a user as one of Śrīla Prabhupāda's direct disciples by
// pointing them at a disciple record. The id is validated against Mongo first
// so a typo can't create a link that renders as an unnamed badge forever.
// Returns ErrAdminUserNotFound, ErrDiscipleNotFound, or
// repository.ErrDiscipleClaimed.
func (s *AdminService) LinkDisciple(ctx context.Context, userID, discipleID string) error {
if s.disciples == nil {
return ErrDisciplesUnavailable
}
doc, err := s.disciples.FindByID(ctx, strings.TrimSpace(discipleID))
if err != nil {
return err
}
if doc == nil {
return ErrDiscipleNotFound
}
found, previous, err := s.repo.LinkDisciple(ctx, userID, doc.ID.Hex())
if err != nil {
return err
}
if !found {
return ErrAdminUserNotFound
}
// Everything below is first-link-only. Correcting a mis-link (previous
// != nil) must not greet the devotee twice or re-announce them.
if previous != nil && *previous != "" {
return nil
}
if s.users == nil {
return nil
}
u, err := s.users.FindByID(ctx, userID)
if err != nil || u == nil {
s.logger.Warn("disciple linked but user lookup failed; skipping welcome + announcement",
zap.String("user_id", userID), zap.Error(err))
return nil
}
s.sendDiscipleWelcome(u, strings.TrimSpace(doc.InitiatedName+" "+doc.Suffix))
s.announceDiscipleJoined(ctx, u, doc.ID.Hex())
return nil
}
// announceDiscipleJoined tells the rest of the community that a direct
// disciple has joined — in-app for everyone, plus web push for those who've
// enabled it. No email: see the deliverability reasoning behind keeping
// announcements off the transactional mail domain.
//
// The DB claim is atomic and synchronous (so a double-click can't double
// announce), but the fan-out itself runs detached: pushing to hundreds of
// devotees is one external HTTP call each, far too slow to hold the admin's
// request open. `context.WithoutCancel` keeps it alive past the response, with
// a hard ceiling so a wedged run can't leak forever.
func (s *AdminService) announceDiscipleJoined(ctx context.Context, u *model.User, discipleID string) {
if s.notif == nil {
return
}
if u.BannedAt != nil {
// Don't announce a suspended account to the community.
s.logger.Info("disciple linked on a suspended account; skipping announcement",
zap.String("user_id", u.ID))
return
}
claimed, err := s.repo.ClaimDiscipleAnnouncement(ctx, u.ID, discipleID)
if err != nil {
s.logger.Warn("disciple announcement claim failed; skipping broadcast",
zap.String("user_id", u.ID), zap.Error(err))
return
}
if !claimed {
// Already announced previously — nothing to do.
return
}
bg, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Minute)
go func() {
defer cancel()
if err := s.notif.BroadcastDiscipleJoined(bg, u); err != nil {
s.logger.Error("disciple-joined broadcast failed",
zap.String("user_id", u.ID), zap.Error(err))
}
}()
}
// sendDiscipleWelcome emails the newly-recognised disciple. Fire-and-forget,
// exactly like the feedback emails: the link is already committed, so a mail
// failure must not fail the admin's action — it logs and moves on.
func (s *AdminService) sendDiscipleWelcome(u *model.User, discipleName string) {
if u.Email == nil || strings.TrimSpace(*u.Email) == "" {
// OAuth-only account with no address on file — nothing to send.
s.logger.Info("disciple linked; no email on file, skipping welcome",
zap.String("user_id", u.ID))
return
}
var buf bytes.Buffer
if err := discipleWelcomeEmailTpl.Execute(&buf, map[string]any{
"RecipientName": strings.TrimSpace(u.Name),
"DiscipleName": discipleName,
"ProfileURL": s.frontendURL + "/u/" + u.Handle,
}); err != nil {
s.logger.Warn("render disciple welcome email failed", zap.Error(err))
return
}
if err := s.mail.Send(strings.TrimSpace(*u.Email),
"Welcome, and thank you — Prabhupāda Says", buf.String()); err != nil {
s.logger.Warn("disciple welcome email send failed",
zap.String("user_id", u.ID), zap.Error(err))
}
}
// UnlinkDisciple clears a user's direct-disciple link. Idempotent — clearing an
// already-clear link succeeds.
func (s *AdminService) UnlinkDisciple(ctx context.Context, userID string) error {
ok, err := s.repo.UnlinkDisciple(ctx, userID)
if err != nil {
return err
}
if !ok {
return ErrAdminUserNotFound
}
return nil
}
func (s *AdminService) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) {
return s.repo.ListPosts(ctx, q, limit, offset)
}
func (s *AdminService) ListCollections(ctx context.Context, q string, limit, offset int) ([]model.AdminCollection, int, error) {
return s.repo.ListCollections(ctx, q, limit, offset)
}
func (s *AdminService) ListFeedback(ctx context.Context, status string, limit, offset int) ([]model.AdminFeedback, int, error) {
return s.repo.ListFeedback(ctx, status, limit, offset)
}
func (s *AdminService) ListReports(ctx context.Context, reportType, q string, limit, offset int) ([]model.AdminReport, int, error) {
return s.repo.ListReports(ctx, reportType, q, limit, offset)
}
// NotifyUser sends a free-text admin message to one user (in-app + push +
// email). Returns ErrAdminUserNotFound if the id doesn't exist.
func (s *AdminService) NotifyUser(ctx context.Context, userID, title, body string) error {
exists, err := s.repo.UserExists(ctx, userID)
if err != nil {
return err
}
if !exists {
return ErrAdminUserNotFound
}
return s.notif.NotifyAdminMessage(ctx, userID, title, body)
}
// ReplyFeedback stores the reply and emails it to the submitter when an
// address is known. It does not resolve the row (resolve is a separate
// action). Returns found=false if the id is unknown, and emailed=true when a
// reply email was actually sent.
func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (found, emailed bool, err error) {
email, name, ok, err := s.repo.SaveReply(ctx, id, message)
if err != nil || !ok {
return ok, false, err
}
if email == nil || strings.TrimSpace(*email) == "" {
// Reply stored, but the submitter was anonymous with no contact email.
return true, false, nil
}
var buf bytes.Buffer
if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{
"RecipientName": name,
"Reply": message,
"URL": s.frontendURL,
}); err != nil {
s.logger.Warn("render feedback reply email failed", zap.Error(err))
return true, false, nil
}
if err := s.mail.Send(strings.TrimSpace(*email), "Re: your feedback — Prabhupāda Says", buf.String()); err != nil {
s.logger.Warn("feedback reply email send failed", zap.Error(err))
return true, false, nil
}
return true, true, nil
}
// ResolveReport stamps a report resolved. found=true when this call resolved a
// previously-open report (idempotent: a no-op on an already-resolved/unknown id
// returns false).
func (s *AdminService) ResolveReport(ctx context.Context, reportType, id, note string) (bool, error) {
return s.repo.ResolveReport(ctx, reportType, id, note)
}
// ─── Moderation actions ──────────────────────────────────────────────────────
// SetPostHidden hides or unhides any post (found=false if unknown id).
func (s *AdminService) SetPostHidden(ctx context.Context, postID string, hidden bool) (bool, error) {
return s.repo.SetPostHidden(ctx, postID, hidden)
}
// DeletePost soft-deletes a post (hidden from all reads, restorable).
func (s *AdminService) DeletePost(ctx context.Context, postID string) (bool, error) {
return s.repo.SetPostDeleted(ctx, postID, true)
}
// RestorePost reverses a soft-delete.
func (s *AdminService) RestorePost(ctx context.Context, postID string) (bool, error) {
return s.repo.SetPostDeleted(ctx, postID, false)
}
// DeleteComment soft-deletes a comment (restorable).
func (s *AdminService) DeleteComment(ctx context.Context, commentID string) (bool, error) {
return s.repo.SetCommentDeleted(ctx, commentID, true)
}
// RestoreComment reverses a soft-delete.
func (s *AdminService) RestoreComment(ctx context.Context, commentID string) (bool, error) {
return s.repo.SetCommentDeleted(ctx, commentID, false)
}
// SetUserBanned suspends or restores an account.
func (s *AdminService) SetUserBanned(ctx context.Context, userID string, banned bool) (bool, error) {
return s.repo.SetUserBanned(ctx, userID, banned)
}
// SetUserLimits writes per-user save-limit overrides (a nil field resets that
// limit to the plan default). found=false when the id is unknown.
func (s *AdminService) SetUserLimits(ctx context.Context, userID string, bookmark, publicCollection, privateCollection *int) (bool, error) {
return s.repo.SetUserLimits(ctx, userID, bookmark, publicCollection, privateCollection)
}
// feedbackReplyEmailTpl renders the reply email. Reply text is auto-escaped by
// html/template, so admin-entered text can't inject markup.
// discipleWelcomeEmailTpl greets a devotee an admin has just recognised as one
// of Śrīla Prabhupāda's own initiated disciples. Same Vaishnava styling and
// 17px base as the other author-facing templates.
var discipleWelcomeEmailTpl = template.Must(template.New("discipleWelcome").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p>Please accept our humble obeisances.</p>
<p>It is our great fortune to welcome you to
<a href="{{.ProfileURL}}" style="color:#9a6b2f;font-weight:600">prabhupadasays.org</a>.
We have recognised your account as belonging to
{{if .DiscipleName}}<strong>{{.DiscipleName}}</strong>, one of{{else}}one of{{end}}
Śrīla Prabhupāda's own initiated disciples.</p>
<p>Your profile now carries His Divine Grace's picture beside your name, and
your name is highlighted in our directory of his disciples — so every devotee
who visits knows they are hearing from someone who served Śrīla Prabhupāda
personally.</p>
<p>This whole library exists to preserve and spread Śrīla Prabhupāda's
<em>vāṇī</em>. Your presence here — your memories, your realisations, your
corrections — is a gift to the devotees who came after. We would be honoured
by whatever you care to share.</p>
<p style="margin:1.25em 0">
<a href="{{.ProfileURL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View your profile</a>
</p>
<p>If anything about your account needs correcting, simply reply to this
email and we will attend to it personally.</p>
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p>Thank you for writing to us. Here's our reply to your feedback:</p>
<blockquote style="white-space:pre-line;border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote>
<p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
</p>
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
+25
View File
@@ -0,0 +1,25 @@
package service
import (
"context"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// ListShortURLs pages the shortened-URL table for the admin panel.
func (s *AdminService) ListShortURLs(ctx context.Context, q string, limit, offset int) ([]model.AdminShortURL, int, error) {
return s.repo.ListShortURLs(ctx, q, limit, offset)
}
// ShortURLStats computes the today / this-week / this-month / all-time short-URL
// summary (and an optional custom range). The fixed windows are UTC-calendar
// based: today from 00:00 UTC, week from Monday 00:00 UTC, month from the 1st.
func (s *AdminService) ShortURLStats(ctx context.Context, from, to *time.Time) (*model.ShortURLStats, error) {
now := time.Now().UTC()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
// Monday as the first day of the week (Weekday: Sun=0 … Sat=6).
weekStart := todayStart.AddDate(0, 0, -((int(now.Weekday()) + 6) % 7))
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
return s.repo.ShortURLStats(ctx, todayStart, weekStart, monthStart, from, to)
}
@@ -0,0 +1,33 @@
// Package service — AudioProgressService is a thin passthrough over
// AudioProgressRepo. The audio player's resume points carry no server-side
// business logic (the frontend owns the schema and the newest-wins merge
// is enforced in SQL), so this layer exists only to keep the
// handler → service → repository shape consistent with the rest of the API.
package service
import (
"context"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
type AudioProgressService struct {
repo *repository.AudioProgressRepo
}
func NewAudioProgressService(repo *repository.AudioProgressRepo) *AudioProgressService {
return &AudioProgressService{repo: repo}
}
func (s *AudioProgressService) List(ctx context.Context, userID string) ([]model.AudioProgress, error) {
return s.repo.ListByUser(ctx, userID)
}
func (s *AudioProgressService) Upsert(ctx context.Context, userID string, items []model.AudioProgress) error {
return s.repo.Upsert(ctx, userID, items)
}
func (s *AudioProgressService) Delete(ctx context.Context, userID, key string) error {
return s.repo.Delete(ctx, userID, key)
}
+20
View File
@@ -21,6 +21,7 @@ import (
"github.com/prabhupadasays/spsays-bff/internal/repository"
"github.com/prabhupadasays/spsays-bff/pkg/auth"
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
"github.com/prabhupadasays/spsays-bff/pkg/revalidate"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
@@ -34,6 +35,7 @@ var (
ErrEmailAlreadyVerified = errors.New("email already verified")
ErrWeakPassword = errors.New("password must be at least 8 characters")
ErrHandleTaken = errors.New("handle already in use")
ErrAccountSuspended = errors.New("this account has been suspended")
)
// AuthService bundles every flow that mutates auth state.
@@ -45,12 +47,17 @@ type AuthService struct {
mail mailer.Mailer
logger *zap.Logger
frontend string // FrontendURL — used to build reset / verify links
reval *revalidate.Client // optional — pings sitemap revalidation on new signups
// TTLs — surfaced as fields so tests can shrink them.
PasswordResetTTL time.Duration
EmailVerifyTTL time.Duration
}
// SetRevalidator wires the frontend cache-revalidation client. Optional —
// when unset, new-signup sitemap pings are simply skipped.
func (s *AuthService) SetRevalidator(r *revalidate.Client) { s.reval = r }
// NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if
// the OAuth flows aren't wired (handler then returns 503).
func NewAuthService(
@@ -140,6 +147,9 @@ func (s *AuthService) SignUp(ctx context.Context, email, password, name string)
s.logger.Warn("verification email failed", zap.String("user", u.ID), zap.Error(err))
}
// New public profile → refresh the sitemap so it's crawlable promptly.
s.reval.Trigger("sitemap")
return s.issueSession(u)
}
@@ -184,6 +194,11 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*Login
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
// Suspended accounts can't sign in (checked after the password so we don't
// reveal ban status to someone who doesn't know the password).
if u.BannedAt != nil {
return nil, ErrAccountSuspended
}
// OTP gate — unverified email AND account is older than the grace
// window. Mint an OTP and signal the caller to collect it. We do NOT
@@ -224,6 +239,9 @@ func (s *AuthService) VerifyLoginOTP(ctx context.Context, email, password, otp s
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
if u.BannedAt != nil {
return nil, ErrAccountSuspended
}
otpHash := hashToken(otp)
tok, err := s.tokens.FindValidForUser(ctx, u.ID, otpHash, model.PurposeLoginOTP)
@@ -445,6 +463,8 @@ func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
return nil, err
}
// New public profile → refresh the sitemap so it's crawlable promptly.
s.reval.Trigger("sitemap")
return s.issueSession(u)
}
+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
}
+6
View File
@@ -92,6 +92,12 @@ func (s *FeedbackService) Submit(ctx context.Context, userID *string, category,
return f, nil
}
// ListForUser returns the signed-in user's own submissions (newest
// first) with status and any admin reply, for the "My feedback" page.
func (s *FeedbackService) ListForUser(ctx context.Context, userID string, limit int) ([]model.Feedback, error) {
return s.repo.ListByUser(ctx, userID, limit)
}
// ErrAlreadyResolved is returned by Resolve when the row was already
// closed (or doesn't exist). The handler maps it to a benign redirect
// rather than an error — the end state the admin wanted is already true.
+507 -26
View File
@@ -15,9 +15,12 @@ package service
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"slices"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
@@ -31,6 +34,16 @@ const (
NotifFollowRequest = "follow_request"
NotifFollowAccepted = "follow_accepted"
NotifPostLike = "post_like"
NotifPostMention = "post_mention"
NotifPostComment = "post_comment"
NotifCommentReply = "comment_reply"
NotifCommentMention = "comment_mention"
// NotifDiscipleJoined is broadcast to the whole community when an admin
// recognises an account as one of Śrīla Prabhupāda's own initiated
// disciples. The actor is the disciple, so it renders and links exactly
// like the other actor-driven kinds.
NotifDiscipleJoined = "disciple_joined"
// Reminder kinds are delivered live (email + push + a websocket toast)
// but NOT persisted — they're ephemeral nudges, not history.
@@ -39,8 +52,24 @@ const (
NotifChantIncomplete = "chant_incomplete_reminder"
)
// PrefCategories groups notification kinds into the coarser event categories
// the Settings matrix exposes — storage stays kind-level (so backfilled
// opt-outs map exactly), but the UI reads/writes whole categories.
var PrefCategories = map[string][]string{
"follows": {NotifFollowRequest, NotifFollowAccepted},
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
"community": {NotifDiscipleJoined},
}
// PrefChannels is every delivery channel a matrix cell can address.
var PrefChannels = []string{
repository.ChannelInApp, repository.ChannelEmail, repository.ChannelPush,
}
type NotificationService struct {
notifs *repository.NotificationRepo
prefs *repository.NotificationPrefsRepo
users *repository.UserRepo
mail mailer.Mailer
rt *realtime.SoketiClient // optional — nil disables WebSocket push
@@ -49,9 +78,10 @@ type NotificationService struct {
frontendURL string
}
func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, push *PushSender, logger *zap.Logger, frontendURL string) *NotificationService {
func NewNotificationService(notifs *repository.NotificationRepo, prefs *repository.NotificationPrefsRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, push *PushSender, logger *zap.Logger, frontendURL string) *NotificationService {
return &NotificationService{
notifs: notifs,
prefs: prefs,
users: users,
mail: mail,
rt: rt,
@@ -61,6 +91,27 @@ func NewNotificationService(notifs *repository.NotificationRepo, users *reposito
}
}
// disabledPrefs loads the recipient's notification opt-outs. Fails open
// (empty set): absence means enabled, and a prefs lookup hiccup must never
// silence delivery for users who opted out of nothing.
func (s *NotificationService) disabledPrefs(ctx context.Context, userID string) map[string]struct{} {
if s.prefs == nil {
return nil
}
set, err := s.prefs.DisabledSet(ctx, userID)
if err != nil {
s.logger.Warn("load notification prefs failed", zap.String("user", userID), zap.Error(err))
return nil
}
return set
}
// prefOff reports whether (kind, channel) is opted out in a disabled set.
func prefOff(set map[string]struct{}, kind, channel string) bool {
_, off := set[repository.PrefKey(kind, channel)]
return off
}
// List returns the caller's notifications, newest first.
func (s *NotificationService) List(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
if limit <= 0 || limit > 100 {
@@ -83,6 +134,97 @@ func (s *NotificationService) MarkAllRead(ctx context.Context, userID string) er
return s.notifs.MarkAllRead(ctx, userID)
}
// ErrUnknownPref marks a PrefMatrix/SetPref call with an unrecognized
// category or channel (a client bug — surfaced as a 400).
var ErrUnknownPref = errors.New("unknown notification preference")
// PrefMatrix returns the resolved Settings matrix: category → channel →
// enabled. A cell reads enabled only when every kind in the category is
// enabled on that channel, so a backfilled single-kind opt-out (like emails)
// shows as the category being off rather than silently on.
func (s *NotificationService) PrefMatrix(ctx context.Context, userID string) (map[string]map[string]bool, error) {
off, err := s.prefs.DisabledSet(ctx, userID)
if err != nil {
// The handler sanitizes this into a generic 500 — log the real
// cause here or it's invisible in production.
s.logger.Error("notification prefs: load failed", zap.String("user", userID), zap.Error(err))
return nil, err
}
matrix := make(map[string]map[string]bool, len(PrefCategories))
for category, kinds := range PrefCategories {
row := make(map[string]bool, len(PrefChannels))
for _, ch := range PrefChannels {
enabled := true
for _, kind := range kinds {
if prefOff(off, kind, ch) {
enabled = false
break
}
}
row[ch] = enabled
}
matrix[category] = row
}
return matrix, nil
}
// SetPref writes one category × channel cell, fanning out to every kind in
// the category, and keeps the legacy users columns in sync for old clients.
func (s *NotificationService) SetPref(ctx context.Context, userID, category, channel string, enabled bool) error {
kinds, ok := PrefCategories[category]
if !ok {
return ErrUnknownPref
}
if !slices.Contains(PrefChannels, channel) {
return ErrUnknownPref
}
var err error
if enabled {
err = s.prefs.Enable(ctx, userID, kinds, []string{channel})
} else {
err = s.prefs.Disable(ctx, userID, kinds, []string{channel})
}
if err == nil {
err = s.prefs.SyncLegacyColumns(ctx, userID)
}
if err != nil {
s.logger.Error("notification prefs: write failed",
zap.String("user", userID), zap.String("category", category),
zap.String("channel", channel), zap.Error(err))
}
return err
}
// LikeEmailPref / SetLikeEmailPref back the legacy like-email endpoint —
// now a shim over the prefs table (post_like × email) that dual-writes the
// old users column. Kept until the matrix UI is fully rolled out.
func (s *NotificationService) LikeEmailPref(ctx context.Context, userID string) (bool, error) {
off, err := s.prefs.DisabledSet(ctx, userID)
if err != nil {
s.logger.Error("notification prefs: load failed", zap.String("user", userID), zap.Error(err))
return false, err
}
return !prefOff(off, NotifPostLike, repository.ChannelEmail), nil
}
func (s *NotificationService) SetLikeEmailPref(ctx context.Context, userID string, enabled bool) error {
kinds, channels := []string{NotifPostLike}, []string{repository.ChannelEmail}
var err error
if enabled {
err = s.prefs.Enable(ctx, userID, kinds, channels)
} else {
err = s.prefs.Disable(ctx, userID, kinds, channels)
}
if err == nil {
err = s.prefs.SyncLegacyColumns(ctx, userID)
}
if err != nil {
s.logger.Error("notification prefs: like-email write failed",
zap.String("user", userID), zap.Error(err))
}
return err
}
// NotifyFollowRequest tells `recipientID` that `actorID` asked to follow
// them. Persists the row and emails the recipient (if they have an email
// on file).
@@ -107,13 +249,16 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
if authorID == "" || likerID == "" || authorID == likerID {
return
}
// Aggregate into the single (author, post) like-notification row, getting
// back how many likers it now represents.
count, err := s.notifs.UpsertLike(ctx, authorID, likerID, postID)
off := s.disabledPrefs(ctx, authorID)
// Aggregate into the single (author, post) like-notification row — always,
// even with in-app off: the row carries the like aggregation + email
// throttle state, and read-time filtering keeps it out of the feed.
count, emailDue, err := s.notifs.UpsertLike(ctx, authorID, likerID, postID, likeEmailThrottle)
if err != nil {
s.logger.Warn("aggregate like notification failed",
zap.String("recipient", authorID), zap.Error(err))
count = 1
count, emailDue = 1, false
}
actorUser, _ := s.users.FindByID(ctx, likerID)
@@ -124,36 +269,179 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
body := likeMessage(who, count)
// Realtime nudge — carry actor_count so the toast can read "and N others".
if s.rt != nil {
if s.rt != nil && !prefOff(off, NotifPostLike, repository.ChannelInApp) {
payload := map[string]any{"kind": NotifPostLike, "actor_count": count}
if actorUser != nil {
payload["actor_name"] = actorDisplay(actorUser)
payload["actor_handle"] = actorUser.Handle
payload["actor_avatar_url"] = actorUser.AvatarURL
}
if err := s.rt.Trigger(ctx, "notifications."+authorID, "notification", payload); err != nil {
if err := s.rt.TriggerUser(ctx, authorID, "notification", payload); err != nil {
s.logger.Warn("realtime like publish failed", zap.String("recipient", authorID), zap.Error(err))
}
}
// Web Push — tapping opens the author's own feed anchored to the post.
// Tagged per-post so repeated likes collapse into one notification.
if s.push == nil {
return
}
// Tapping a notification opens the author's own feed anchored to the post.
author, _ := s.users.FindByID(ctx, authorID)
url := s.frontendURL + "/notifications"
if author, err := s.users.FindByID(ctx, authorID); err == nil && author != nil {
if author != nil {
url = s.frontendURL + "/u/" + author.Handle + "/posts#post-" + postID
}
s.push.SendToUser(ctx, authorID, PushPayload{
Title: "New Like",
// Web Push — tagged per-post so repeated likes collapse into one.
if s.push != nil && !prefOff(off, NotifPostLike, repository.ChannelPush) {
s.push.SendToUser(ctx, authorID, PushPayload{
Title: "New Like",
Body: body,
URL: url,
Icon: s.iconURL(),
Tag: NotifPostLike + ":" + postID,
})
}
// Aggregated email — only when the throttle says one is due (so a burst of
// likes yields a single email), the author has an address, and they
// haven't opted out. `body` already aggregates ("X and N others …").
if !emailDue || author == nil || author.Email == nil || strings.TrimSpace(*author.Email) == "" {
return
}
if prefOff(off, NotifPostLike, repository.ChannelEmail) {
return
}
var buf bytes.Buffer
if err := likeEmailTpl.Execute(&buf, map[string]any{
"RecipientName": author.Name,
"Message": body,
"URL": url,
}); err != nil {
s.logger.Warn("render like email failed", zap.Error(err))
return
}
if err := s.mail.Send(strings.TrimSpace(*author.Email), "New likes on your post — Prabhupāda Says", buf.String()); err != nil {
s.logger.Warn("like email send failed", zap.String("to", *author.Email), zap.Error(err))
}
}
// likeEmailThrottle is the minimum gap between aggregated like emails for a
// single post — a burst of likes within the window produces one email.
const likeEmailThrottle = 6 * time.Hour
// NotifyPostMention tells `mentionedID` that `actorID` (the post author)
// tagged them in a post. Persisted row + realtime + Web Push; no email
// (push/in-app is enough, and a post can tag many people). Skips self-tags.
func (s *NotificationService) NotifyPostMention(ctx context.Context, mentionedID, actorID, postID string) {
if mentionedID == "" || actorID == "" || mentionedID == actorID {
return
}
off := s.disabledPrefs(ctx, mentionedID)
if err := s.notifs.CreateMention(ctx, mentionedID, actorID, postID); err != nil {
s.logger.Warn("persist mention notification failed",
zap.String("recipient", mentionedID), zap.Error(err))
}
actorUser, _ := s.users.FindByID(ctx, actorID)
if !prefOff(off, NotifPostMention, repository.ChannelInApp) {
s.publishRealtime(ctx, mentionedID, NotifPostMention, actorUser)
}
if s.push == nil || prefOff(off, NotifPostMention, repository.ChannelPush) {
return
}
who := "Someone"
url := s.frontendURL + "/notifications"
if actorUser != nil {
who = actorDisplay(actorUser)
url = s.frontendURL + "/u/" + actorUser.Handle + "/posts#post-" + postID
}
s.push.SendToUser(ctx, mentionedID, PushPayload{
Title: "You were mentioned",
Body: who + " mentioned you in a post",
URL: url,
Icon: s.iconURL(),
Tag: NotifPostMention + ":" + postID,
})
}
// NotifyPostComment tells the post author that `commenterID` commented on
// their post. NotifyCommentReply tells `recipientID` (the author of the
// comment being replied to) that `replierID` replied. Both: persisted row +
// realtime + Web Push, no email (comments are interactive, not digest-worthy).
// Self-actions are skipped.
func (s *NotificationService) NotifyPostComment(ctx context.Context, postID, authorID, commenterID string) {
s.notifyComment(ctx, NotifPostComment, postID, authorID, commenterID)
}
func (s *NotificationService) NotifyCommentReply(ctx context.Context, postID, recipientID, replierID string) {
s.notifyComment(ctx, NotifCommentReply, postID, recipientID, replierID)
}
func (s *NotificationService) notifyComment(ctx context.Context, kind, postID, recipientID, actorID string) {
if recipientID == "" || actorID == "" || recipientID == actorID {
return
}
off := s.disabledPrefs(ctx, recipientID)
if err := s.notifs.CreateComment(ctx, recipientID, actorID, postID, kind); err != nil {
s.logger.Warn("persist comment notification failed",
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
}
actorUser, _ := s.users.FindByID(ctx, actorID)
if !prefOff(off, kind, repository.ChannelInApp) {
s.publishRealtime(ctx, recipientID, kind, actorUser)
}
if s.push == nil || prefOff(off, kind, repository.ChannelPush) {
return
}
who := "Someone"
if actorUser != nil {
who = actorDisplay(actorUser)
}
// Tapping it opens the post (via its author's handle — the actor isn't the
// post owner on a reply). Falls back to the notification center.
url := s.frontendURL + "/notifications"
if h, err := s.notifs.PostAuthorHandle(ctx, postID); err == nil && h != "" {
url = s.frontendURL + "/u/" + h + "/posts#post-" + postID
}
title, body := "New comment", who+" commented on your post"
switch kind {
case NotifCommentReply:
title, body = "New reply", who+" replied to your comment"
case NotifCommentMention:
title, body = "You were mentioned", who+" mentioned you in a comment"
}
s.push.SendToUser(ctx, recipientID, PushPayload{
Title: title,
Body: body,
URL: url,
Icon: s.iconURL(),
Tag: NotifPostLike + ":" + postID,
Tag: kind + ":" + postID,
})
}
// NotifyCommentMentions tells each user named by `handles` that `actorID`
// mentioned them in a comment on `postID`. Handles are resolved to users,
// deduped, and the actor plus any `skip` ids (e.g. the post/parent author who
// already got a comment/reply notification) are excluded so nobody is
// double-pinged. Reuses the comment notification path (row + realtime + push).
func (s *NotificationService) NotifyCommentMentions(ctx context.Context, postID, actorID string, handles []string, skip ...string) {
if actorID == "" || len(handles) == 0 {
return
}
skipSet := map[string]bool{actorID: true}
for _, id := range skip {
if id != "" {
skipSet[id] = true
}
}
for _, h := range handles {
u, err := s.users.FindByHandle(ctx, h)
if err != nil || u == nil || skipSet[u.ID] {
continue
}
skipSet[u.ID] = true // dedupe repeats of the same mention
s.notifyComment(ctx, NotifCommentMention, postID, u.ID, actorID)
}
}
// likeMessage renders the aggregated like text: "X liked your post" or
// "X and N others liked your post".
func likeMessage(who string, count int) string {
@@ -168,9 +456,13 @@ func likeMessage(who string, count int) string {
return fmt.Sprintf("%s and %d %s liked your post", who, others, noun)
}
// dispatch persists the notification and sends the matching email. All
// failures are logged, never propagated.
// dispatch persists the notification and sends it over every channel the
// recipient hasn't opted out of. All failures are logged, never propagated.
func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) {
off := s.disabledPrefs(ctx, recipientID)
// Always persist — the in-app pref filters display at read time, and
// the row is the durable record either way.
actor := actorID
if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil {
s.logger.Warn("persist notification failed",
@@ -185,15 +477,21 @@ func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID
}
// Realtime push (best-effort) — a lightweight nudge so the client can
// bump the bell + toast immediately. Fires regardless of whether the
// recipient has an email; authoritative data still comes from REST.
s.publishRealtime(ctx, recipientID, kind, actorUser)
// bump the bell + toast immediately. Part of the in-app channel.
if !prefOff(off, kind, repository.ChannelInApp) {
s.publishRealtime(ctx, recipientID, kind, actorUser)
}
// Web Push (best-effort) — reaches the recipient even with the app
// closed. Same seam as realtime/email; nil sender is a no-op.
s.publishPush(ctx, recipientID, kind, actorUser)
if !prefOff(off, kind, repository.ChannelPush) {
s.publishPush(ctx, recipientID, kind, actorUser)
}
// Email (best-effort).
if prefOff(off, kind, repository.ChannelEmail) {
return
}
recipient, err := s.users.FindByID(ctx, recipientID)
if err != nil {
s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err))
@@ -236,7 +534,7 @@ func (s *NotificationService) publishRealtime(ctx context.Context, recipientID,
payload["actor_handle"] = actor.Handle
payload["actor_avatar_url"] = actor.AvatarURL
}
if err := s.rt.Trigger(ctx, "notifications."+recipientID, "notification", payload); err != nil {
if err := s.rt.TriggerUser(ctx, recipientID, "notification", payload); err != nil {
s.logger.Warn("realtime notification publish failed",
zap.String("recipient", recipientID), zap.String("kind", kind), zap.Error(err))
}
@@ -247,20 +545,25 @@ func (s *NotificationService) publishRealtime(ctx context.Context, recipientID,
// and an email. Best-effort; nothing is persisted to the notification
// center. `url` is where tapping the notification lands.
func (s *NotificationService) SendReminder(ctx context.Context, userID, kind, title, body, url string) {
off := s.disabledPrefs(ctx, userID)
// Realtime toast — a distinct "reminder" event so the client toasts
// without bumping the unread badge (reminders have no DB row).
if s.rt != nil {
_ = s.rt.Trigger(ctx, "notifications."+userID, "reminder", map[string]any{
if s.rt != nil && !prefOff(off, kind, repository.ChannelInApp) {
_ = s.rt.TriggerUser(ctx, userID, "reminder", map[string]any{
"kind": kind, "title": title, "body": body, "url": url,
})
}
// Web Push (reaches closed apps).
if s.push != nil {
if s.push != nil && !prefOff(off, kind, repository.ChannelPush) {
s.push.SendToUser(ctx, userID, PushPayload{Title: title, Body: body, URL: url, Icon: s.iconURL(), Tag: kind, RequireInteraction: true})
}
// Email (only if there's an address on file).
if prefOff(off, kind, repository.ChannelEmail) {
return
}
recipient, err := s.users.FindByID(ctx, userID)
if err != nil || recipient == nil || recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
return
@@ -316,6 +619,150 @@ func (s *NotificationService) publishPush(ctx context.Context, recipientID, kind
})
}
// NotifyAdminMessage delivers a free-text message from an admin to one user:
// a persisted in-app notification (kind 'admin_message') plus best-effort
// realtime, Web Push, and email. Admin messages bypass the per-kind opt-out
// matrix (there's no category for them) — they're deliberate, low-volume, and
// operationally important. Returns an error only if the row couldn't be
// persisted; the three delivery channels are best-effort.
func (s *NotificationService) NotifyAdminMessage(ctx context.Context, recipientID, title, body string) error {
if err := s.notifs.CreateAdminMessage(ctx, recipientID, body); err != nil {
return err
}
url := s.frontendURL + "/notifications"
if s.rt != nil {
if err := s.rt.TriggerUser(ctx, recipientID, "notification", map[string]any{
"kind": "admin_message",
"body": body,
}); err != nil {
s.logger.Warn("realtime admin message publish failed",
zap.String("recipient", recipientID), zap.Error(err))
}
}
if s.push != nil {
pushTitle := strings.TrimSpace(title)
if pushTitle == "" {
pushTitle = "Prabhupāda Says"
}
s.push.SendToUser(ctx, recipientID, PushPayload{
Title: pushTitle,
Body: body,
URL: url,
Icon: s.iconURL(),
Tag: "admin_message",
})
}
// Email — best-effort, only when the recipient has a verified-or-not address.
recipient, _ := s.users.FindByID(ctx, recipientID)
if recipient != nil && recipient.Email != nil && strings.TrimSpace(*recipient.Email) != "" {
var buf bytes.Buffer
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
"RecipientName": recipient.Name,
"Title": strings.TrimSpace(title),
"Body": body,
"URL": url,
}); err != nil {
s.logger.Warn("render admin message email failed", zap.Error(err))
} else {
subject := strings.TrimSpace(title)
if subject == "" {
subject = "A message from Prabhupāda Says"
}
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, buf.String()); err != nil {
s.logger.Warn("admin message email send failed", zap.Error(err))
}
}
}
return nil
}
// pushBroadcastDelay paces the per-user push fan-out. Web Push is one HTTP
// request per subscription to an external service (FCM/Mozilla/Apple), so a
// tight loop over hundreds of devotees would hammer those endpoints and spike
// outbound connections. A small gap keeps the broadcast a background trickle.
const pushBroadcastDelay = 50 * time.Millisecond
// BroadcastDiscipleJoined tells the community that `actor` — an account an
// admin has just recognised as one of Śrīla Prabhupāda's own initiated
// disciples — has joined.
//
// Two channels, deliberately decoupled by cost:
//
// - In-app: a single bulk INSERT for every user. Cheap and instant, so it
// happens inline and the row is durable the moment this returns.
// - Push + realtime: one call per recipient against external services, so it
// runs paced. The caller is expected to invoke this on a detached,
// time-limited context (not the admin's request context, which dies with
// the response).
//
// No email — announcements are in-app/push only by design; see the
// deliverability reasoning in the disciple-link notes.
//
// Errors from the fan-out are logged, not returned: the announcement is
// already durably recorded in-app, and a failed push to one devotee must not
// abort the rest.
func (s *NotificationService) BroadcastDiscipleJoined(ctx context.Context, actor *model.User) error {
if actor == nil {
return nil
}
written, err := s.notifs.BroadcastToAllUsers(ctx, NotifDiscipleJoined, actor.ID)
if err != nil {
return err
}
s.logger.Info("disciple-joined broadcast: in-app rows written",
zap.String("actor", actor.Handle), zap.Int64("recipients", written))
// Everything below is best-effort delivery on top of the durable row.
recipients, err := s.notifs.PushRecipientsForKind(ctx, NotifDiscipleJoined, actor.ID)
if err != nil {
s.logger.Warn("disciple-joined broadcast: push recipient lookup failed", zap.Error(err))
return nil
}
if len(recipients) == 0 || s.push == nil {
return nil
}
name := actorDisplay(actor)
payload := PushPayload{
Title: "A direct disciple has joined",
Body: name + " — an initiated disciple of Śrīla Prabhupāda — is now on Prabhupāda Says.",
URL: s.frontendURL + "/u/" + actor.Handle,
Icon: s.iconURL(),
Tag: NotifDiscipleJoined,
}
sent := 0
for _, uid := range recipients {
// Honour cancellation so a shutdown (or the caller's timeout) stops
// the fan-out cleanly rather than running to completion detached.
if ctx.Err() != nil {
s.logger.Warn("disciple-joined broadcast: push fan-out cut short",
zap.Int("sent", sent), zap.Int("total", len(recipients)), zap.Error(ctx.Err()))
return nil
}
if s.rt != nil {
if err := s.rt.TriggerUser(ctx, uid, "notification", map[string]any{
"kind": NotifDiscipleJoined,
"actor_name": name,
"actor_handle": actor.Handle,
}); err != nil {
s.logger.Debug("disciple-joined realtime publish failed",
zap.String("recipient", uid), zap.Error(err))
}
}
s.push.SendToUser(ctx, uid, payload)
sent++
time.Sleep(pushBroadcastDelay)
}
s.logger.Info("disciple-joined broadcast: push fan-out complete",
zap.String("actor", actor.Handle), zap.Int("recipients", sent))
return nil
}
// iconURL is the absolute URL of the notification icon (the app's square
// icon), so it renders regardless of the client's base path.
func (s *NotificationService) iconURL() string {
@@ -402,3 +849,37 @@ var reminderEmailTpl = template.Must(template.New("reminder").Parse(`<div style=
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
// Aggregated like email. Message is "X and N others liked your post"; the CTA
// opens the author's post.
var likeEmailTpl = template.Must(template.New("postLike").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p>{{.Message}}.</p>
<p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">View your post</a>
</p>
<p style="margin-top:1.5em;font-size:14px;color:#7a6a55">You can turn off like emails any time in Settings → Notifications.</p>
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
// adminMessageEmailTpl renders an admin → user direct message. Body is
// auto-escaped by html/template, so admin-entered text can't inject markup.
var adminMessageEmailTpl = template.Must(template.New("adminMessage").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
{{if .Title}}<p style="font-weight:600;font-size:19px;margin-bottom:0.5em">{{.Title}}</p>{{end}}
<p style="white-space:pre-line">{{.Body}}</p>
<p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
</p>
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
+23 -47
View File
@@ -3,19 +3,16 @@ package service
import (
"errors"
"fmt"
"github.com/prabhupadasays/spsays-bff/internal/model"
)
// Plan caps for the social features. Updates here propagate everywhere via
// EnforceBookmarkLimit / EnforceCollectionLimit.
// Plan identifiers. The numeric caps live in model.ResolveLimits (so the self
// user payload and these enforcers agree), and per-user admin overrides take
// precedence over the plan default.
const (
PlanFree = "free"
PlanPremium = "premium"
freeBookmarkLimit = 1
premiumBookmarkLimit = 5
freeCollectionPerVisibilityLimit = 5 // 5 public AND 5 private separately
// Premium has no collection cap.
)
// ErrPlanLimit signals that a free-plan limit blocks the requested action.
@@ -38,50 +35,29 @@ func (e *PlanLimitError) Error() string {
func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit }
// EnforceBookmarkLimit returns nil if the user is allowed to create one more
// bookmark, or a PlanLimitError otherwise.
func EnforceBookmarkLimit(plan string, currentCount int) error {
limit := bookmarkLimitFor(plan)
if currentCount >= limit {
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: plan}
// bookmark, or a PlanLimitError otherwise. The cap is the user's effective
// bookmark limit (plan default, or an admin override). A negative cap means
// unlimited.
func EnforceBookmarkLimit(u *model.User, currentCount int) error {
limit := u.EffectiveLimits().Bookmarks
if limit >= 0 && currentCount >= limit {
return &PlanLimitError{Resource: "bookmarks", Limit: limit, Plan: u.Plan}
}
return nil
}
// EnforceCollectionLimit returns nil if the user is allowed to create one
// more collection of the given visibility.
//
// `isPublic=true` → checks against public-collection cap
// `isPublic=false` → checks against private-collection cap
// Premium users have no cap → always nil.
func EnforceCollectionLimit(plan string, isPublic bool, currentCount int) error {
if plan == PlanPremium {
return nil
// EnforceCollectionLimit returns nil if the user is allowed to create one more
// collection of the given visibility. The cap is the user's effective
// public/private collection limit (plan default, or an admin override); a
// negative cap means unlimited.
func EnforceCollectionLimit(u *model.User, isPublic bool, currentCount int) error {
l := u.EffectiveLimits()
limit, resource := l.PrivateCollections, "private collections"
if isPublic {
limit, resource = l.PublicCollections, "public collections"
}
limit := freeCollectionPerVisibilityLimit
if currentCount >= limit {
resource := "private collections"
if isPublic {
resource = "public collections"
}
return &PlanLimitError{Resource: resource, Limit: limit, Plan: plan}
if limit >= 0 && currentCount >= limit {
return &PlanLimitError{Resource: resource, Limit: limit, Plan: u.Plan}
}
return nil
}
func bookmarkLimitFor(plan string) int {
if plan == PlanPremium {
return premiumBookmarkLimit
}
return freeBookmarkLimit
}
// BookmarkLimitFor returns the cap (for showing "X / Y used" in the UI).
func BookmarkLimitFor(plan string) int { return bookmarkLimitFor(plan) }
// CollectionLimitFor returns the per-visibility cap. -1 means unlimited.
func CollectionLimitFor(plan string) int {
if plan == PlanPremium {
return -1
}
return freeCollectionPerVisibilityLimit
}
+56 -7
View File
@@ -50,40 +50,89 @@ const (
type ReminderService struct {
repo *repository.ReminderRepo
prefs *repository.NotificationPrefsRepo
users *repository.UserRepo
notif *NotificationService
logger *zap.Logger
sadhnaURL string
}
func NewReminderService(repo *repository.ReminderRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService {
func NewReminderService(repo *repository.ReminderRepo, prefs *repository.NotificationPrefsRepo, users *repository.UserRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService {
return &ReminderService{
repo: repo,
prefs: prefs,
users: users,
notif: notif,
logger: logger,
sadhnaURL: strings.TrimRight(frontendURL, "/") + "/sadhna-streak",
}
}
// Enabled / SetEnabled / SetTimezone back the settings + tz-capture endpoints.
// Enabled / SetEnabled back the legacy all-or-nothing reminder toggle — now a
// shim over the per-kind/channel prefs (dual-writing the legacy users column)
// so old clients and the new matrix stay consistent. "Enabled" means at least
// one reminder kind × channel cell is still on.
func (s *ReminderService) Enabled(ctx context.Context, userID string) (bool, error) {
return s.repo.GetEnabled(ctx, userID)
off, err := s.prefs.DisabledSet(ctx, userID)
if err != nil {
// Handler sanitizes to a generic 500 — log the cause here.
s.logger.Error("reminder prefs: load failed", zap.String("user", userID), zap.Error(err))
return false, err
}
for _, kind := range PrefCategories["sadhna_reminders"] {
for _, ch := range PrefChannels {
if _, disabled := off[repository.PrefKey(kind, ch)]; !disabled {
return true, nil
}
}
}
return false, nil
}
func (s *ReminderService) SetEnabled(ctx context.Context, userID string, enabled bool) error {
return s.repo.SetEnabled(ctx, userID, enabled)
kinds := PrefCategories["sadhna_reminders"]
var err error
if enabled {
err = s.prefs.Enable(ctx, userID, kinds, PrefChannels)
} else {
err = s.prefs.Disable(ctx, userID, kinds, PrefChannels)
}
if err == nil {
err = s.prefs.SyncLegacyColumns(ctx, userID)
}
if err != nil {
s.logger.Error("reminder prefs: write failed", zap.String("user", userID), zap.Error(err))
}
return err
}
// SetTimezone validates the IANA zone before storing so a bad value can never
// break the (timezone-using) candidate scan.
// break the (timezone-using) candidate scan. Legacy aliases are canonicalized
// first (browsers report e.g. "Asia/Calcutta"): Go resolves them but a
// Postgres without the tzdata backward links rejects them in AT TIME ZONE.
func (s *ReminderService) SetTimezone(ctx context.Context, userID, tz string) error {
tz = strings.TrimSpace(tz)
tz = canonicalTZ(strings.TrimSpace(tz))
if tz == "" {
tz = "UTC"
}
if _, err := time.LoadLocation(tz); err != nil {
return ErrInvalidTimezone
}
return s.repo.SetTimezone(ctx, userID, tz)
if err := s.repo.SetTimezone(ctx, userID, tz); err != nil {
return err
}
// Best-effort: backfill the account country from the timezone when it's
// still unset (geo-IP at login is the primary source; SetCountryIfEmpty
// never overwrites it). A no-op when the zone isn't in the table.
if s.users != nil {
if cc := tzCountry[tz]; cc != "" {
if err := s.users.SetCountryIfEmpty(ctx, userID, cc); err != nil {
s.logger.Warn("backfill country from timezone failed",
zap.String("user", userID), zap.String("country", cc), zap.Error(err))
}
}
}
return nil
}
// RunOnce evaluates every due candidate against `now` and fires the reminders
+5
View File
@@ -94,6 +94,11 @@ const fallbackTZ = "Asia/Kolkata"
// genuine UTC user re-reports their real zone on next load.
func (s *SadhnaService) resolveTZ(ctx context.Context, userID string) (string, *time.Location) {
tz, _ := s.logs.UserTimezone(ctx, userID)
// Canonicalize legacy aliases (e.g. stored "Asia/Calcutta") — the name
// is fed into SQL AT TIME ZONE, which rejects aliases on a Postgres
// without the tzdata backward links. Covers rows written before
// SetTimezone started canonicalizing.
tz = canonicalTZ(tz)
if tz == "" || tz == "UTC" {
tz = fallbackTZ
}
+69 -5
View File
@@ -19,6 +19,7 @@ import (
"fmt"
"html/template"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
@@ -70,10 +71,13 @@ type SocialGraphService struct {
reports *repository.ReportRepo
posts *repository.PostRepo
users *repository.UserRepo
notif *NotificationService
mail mailer.Mailer
admins []string
logger *zap.Logger
// chat answers "may the viewer open a DM with this profile?" — the same
// rule OpenDM enforces, so the button and the action can't disagree.
chat *repository.ChatRepo
notif *NotificationService
mail mailer.Mailer
admins []string
logger *zap.Logger
}
func NewSocialGraphService(
@@ -82,6 +86,7 @@ func NewSocialGraphService(
reports *repository.ReportRepo,
posts *repository.PostRepo,
users *repository.UserRepo,
chat *repository.ChatRepo,
notif *NotificationService,
mail mailer.Mailer,
admins []string,
@@ -89,7 +94,8 @@ func NewSocialGraphService(
) *SocialGraphService {
return &SocialGraphService{
follows: follows, blocks: blocks, reports: reports, posts: posts,
users: users, notif: notif, mail: mail, admins: admins, logger: logger,
users: users, chat: chat, notif: notif, mail: mail, admins: admins,
logger: logger,
}
}
@@ -293,9 +299,53 @@ func (s *SocialGraphService) Relationship(ctx context.Context, viewerID, targetI
}
out.Blocked = viewerBlocked
out.BlockedBy = blockedByTarget
// A block closes messaging in either direction; otherwise the target's own
// "who can message you" rule decides. Asking the chat repository — rather
// than reimplementing the rule here — is what keeps the offered button and
// the eventual OpenDM in agreement.
switch {
case viewerID == "" || viewerID == targetID:
out.CanMessage = false
case viewerBlocked || blockedByTarget:
out.CanMessage = false
out.MessageRestriction = "blocked"
case s.chat == nil:
out.CanMessage = true
default:
allowed, err := s.chat.CanStartConversation(ctx, viewerID, targetID)
if err != nil {
return nil, err
}
out.CanMessage = allowed
if !allowed {
settings, err := s.chat.Settings(ctx, targetID)
if err != nil {
return nil, err
}
out.MessageRestriction = settings.WhoCanMessage
}
}
return out, nil
}
// IsBlockedBetween reports whether a block exists in EITHER direction
// between two users — viewer→target or target→viewer. Content visibility
// is gated on this (a block either way hides the target's profile feed,
// tagged posts, and follower lists from the viewer), mirroring the
// "viewerBlocked || targetBlocked" rule RequestFollow enforces. Returns
// false for anonymous viewers (empty id) and for the self case.
func (s *SocialGraphService) IsBlockedBetween(ctx context.Context, a, b string) (bool, error) {
if a == "" || b == "" || a == b {
return false, nil
}
aBlockedB, bBlockedA, err := s.blocks.BlockState(ctx, a, b)
if err != nil {
return false, err
}
return aBlockedB || bBlockedA, nil
}
// Counts returns the accepted-follower and accepted-following totals for
// a user.
func (s *SocialGraphService) Counts(ctx context.Context, userID string) (followers, following int, err error) {
@@ -321,6 +371,13 @@ func (s *SocialGraphService) Followers(ctx context.Context, handle, q string, li
return s.follows.ListFollowers(ctx, u.ID, q, limit, offset)
}
// NewUsers returns up to `limit` accounts that joined in the last 24h for the
// feed's "new on the platform" discovery strip — excluding the viewer, anyone
// they already have a follow edge with, and blocked pairs.
func (s *SocialGraphService) NewUsers(ctx context.Context, viewerID string, limit int) ([]model.UserSummary, error) {
return s.users.RecentlyJoined(ctx, viewerID, time.Now().Add(-24*time.Hour), limit)
}
func (s *SocialGraphService) Following(ctx context.Context, handle, q string, limit, offset int) ([]model.UserSummary, int, error) {
u, err := s.users.FindByHandle(ctx, handle)
if err != nil {
@@ -329,6 +386,13 @@ func (s *SocialGraphService) Following(ctx context.Context, handle, q string, li
return s.follows.ListFollowing(ctx, u.ID, q, limit, offset)
}
// IncomingRequests returns the pending follow requests addressed to userID
// (the caller) — the "Friend Requests" tab. Caller-scoped, so it takes the id
// directly rather than a handle.
func (s *SocialGraphService) IncomingRequests(ctx context.Context, userID, q string, limit, offset int) ([]model.UserSummary, int, error) {
return s.follows.ListIncomingRequests(ctx, userID, q, limit, offset)
}
// ProfileExtras is the read used by the public-profile endpoint: the
// target's follower/following counts plus, when a non-owner viewer is
// signed in, their relationship to the target. rel is nil for anonymous
+265 -11
View File
@@ -31,14 +31,17 @@ var (
// ---------------------------------------------------------------------------
type PostService struct {
posts *repository.PostRepo
r2 *repository.R2
likes *repository.LikeRepo
notif *NotificationService // optional; set via SetNotifier
posts *repository.PostRepo
r2 *repository.R2
likes *repository.LikeRepo
mentions *repository.MentionRepo
comments *repository.CommentRepo
feedSeen *repository.FeedRepo // per-user "already served" set for the home feed
notif *NotificationService // optional; set via SetNotifier
}
func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo) *PostService {
return &PostService{posts: posts, r2: r2, likes: likes}
func NewPostService(posts *repository.PostRepo, r2 *repository.R2, likes *repository.LikeRepo, mentions *repository.MentionRepo, comments *repository.CommentRepo, feedSeen *repository.FeedRepo) *PostService {
return &PostService{posts: posts, r2: r2, likes: likes, mentions: mentions, comments: comments, feedSeen: feedSeen}
}
// SetNotifier wires the notification seam after construction (the
@@ -49,7 +52,7 @@ func (s *PostService) SetNotifier(n *NotificationService) { s.notif = n }
// the frontend to send the exact text the user selected. Source URL must be
// a same-origin path (e.g. /books/bg/1/1?hl=…) — we strip the host if one
// was provided so links can't be hijacked.
func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs []string) (*model.Post, error) {
func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, sourceTitle string, imageURLs, mentionUserIDs []string) (*model.Post, error) {
caption = strings.TrimSpace(caption)
if caption == "" {
return nil, errors.New("caption is required")
@@ -64,7 +67,19 @@ func (s *PostService) Create(ctx context.Context, userID, caption, sourceURL, so
if len(imageURLs) > 5 {
return nil, errors.New("a post can hold up to 5 images")
}
return s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs)
p, err := s.posts.Create(ctx, userID, caption, sourceURL, sourceTitle, imageURLs)
if err != nil {
return nil, err
}
// Tag mentioned users (best-effort, additive — a bad id never fails the
// post itself). Reflect the count on the returned post, then notify the
// tagged people (fire-and-forget on a detached context).
if len(mentionUserIDs) > 0 {
_ = s.mentions.AddMany(ctx, p.ID, mentionUserIDs)
_ = s.mentions.HydrateCounts(ctx, []*model.Post{p})
go s.notifyMentions(context.Background(), p.ID, userID, mentionUserIDs)
}
return p, nil
}
// Get returns one post (public). `viewerID` is "" for anonymous callers;
@@ -81,6 +96,12 @@ func (s *PostService) Get(ctx context.Context, id, viewerID string) (*model.Post
if err := s.likes.Hydrate(ctx, viewerID, []*model.Post{p}); err != nil {
return nil, err
}
if err := s.mentions.HydrateCounts(ctx, []*model.Post{p}); err != nil {
return nil, err
}
if err := s.comments.Hydrate(ctx, viewerID, []*model.Post{p}); err != nil {
return nil, err
}
return p, nil
}
@@ -153,6 +174,12 @@ func (s *PostService) Delete(ctx context.Context, postID, callerID string) error
return s.posts.Delete(ctx, postID, callerID)
}
// SetHidden hides/unhides a post from timelines + public collections.
// Owner-only — the repo scopes the update to the caller.
func (s *PostService) SetHidden(ctx context.Context, postID, callerID string, hidden bool) error {
return s.posts.SetHidden(ctx, postID, callerID, hidden)
}
// ReplaceImages swaps the image set on a post. Owner-only — every
// reshare is its own posts row, owned by the resharer, so this works
// uniformly for canonical posts and reshares.
@@ -183,9 +210,92 @@ func (s *PostService) ListByHandle(ctx context.Context, handle, viewerID string,
if err := s.hydrateLikesSlice(ctx, viewerID, posts); err != nil {
return nil, 0, err
}
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
return nil, 0, err
}
if err := s.hydratePinnedSlice(ctx, viewerID, posts); err != nil {
return nil, 0, err
}
return posts, total, nil
}
// Feed is the global, personalised home feed: posts from across the platform,
// prioritising not-yet-liked posts from people the viewer follows. Fully
// hydrated (reshare/like/mention/comment state) plus the viewer↔author
// relationship so the card can show Follow / Follow Back. Returns at most
// `limit` posts; the caller infers "more" from a full page.
func (s *PostService) Feed(ctx context.Context, viewerID string, limit, offset int) ([]model.Post, error) {
if limit <= 0 || limit > 50 {
limit = 10
}
if offset < 0 {
offset = 0
}
// Non-repetition: a per-user Redis "seen" set is the cursor. offset==0 is
// the start of a fresh session, so we clear it; every page then excludes
// already-served ids. Because the feed's tier ordering depends on viewer
// state (a like re-tiers a post), exclusion — not OFFSET — is what keeps
// pages from repeating or skipping rows. With Redis enabled we drive
// selection by the set alone (offset 0); without it we fall back to plain
// offset paging and the client de-dups the occasional drift.
if offset == 0 {
s.feedSeen.Reset(ctx, viewerID)
}
exclude := s.feedSeen.Seen(ctx, viewerID)
queryOffset := offset
if s.feedSeen.Enabled() {
queryOffset = 0
}
posts, err := s.posts.FeedForViewer(ctx, viewerID, limit, queryOffset, exclude)
if err != nil {
return nil, err
}
if err := s.hydrateReshareStatesSlice(ctx, viewerID, posts); err != nil {
return nil, err
}
if err := s.hydrateLikesSlice(ctx, viewerID, posts); err != nil {
return nil, err
}
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
return nil, err
}
if err := s.hydratePinnedSlice(ctx, viewerID, posts); err != nil {
return nil, err
}
if err := s.posts.HydrateViewerRelationships(ctx, viewerID, posts); err != nil {
return nil, err
}
// Record what we served so the next page skips it (no-op without Redis).
if len(posts) > 0 {
ids := make([]string, len(posts))
for i := range posts {
ids[i] = posts[i].ID
}
s.feedSeen.MarkSeen(ctx, viewerID, ids)
}
return posts, nil
}
// ResetFeed clears the viewer's home-feed seen-set — called on logout and when
// the session ends (the page unloads / the realtime socket drops), so a stale
// set doesn't linger in Redis past its owner. No-op without Redis.
func (s *PostService) ResetFeed(ctx context.Context, viewerID string) {
s.feedSeen.Reset(ctx, viewerID)
}
// hydratePinnedSlice fills PinnedComment on a value slice.
func (s *PostService) hydratePinnedSlice(ctx context.Context, viewerID string, posts []model.Post) error {
if len(posts) == 0 {
return nil
}
ptrs := make([]*model.Post, len(posts))
for i := range posts {
ptrs[i] = &posts[i]
}
return s.comments.Hydrate(ctx, viewerID, ptrs)
}
// hydrateLikesSlice fills LikesCount + ViewerHasLiked on a value slice.
func (s *PostService) hydrateLikesSlice(ctx context.Context, viewerID string, posts []model.Post) error {
if len(posts) == 0 {
@@ -198,6 +308,75 @@ func (s *PostService) hydrateLikesSlice(ctx context.Context, viewerID string, po
return s.likes.Hydrate(ctx, viewerID, ptrs)
}
// hydrateMentionsSlice fills MentionsCount on a value slice.
func (s *PostService) hydrateMentionsSlice(ctx context.Context, posts []model.Post) error {
if len(posts) == 0 {
return nil
}
ptrs := make([]*model.Post, len(posts))
for i := range posts {
ptrs[i] = &posts[i]
}
return s.mentions.HydrateCounts(ctx, ptrs)
}
// SuggestMentions returns prioritised mention suggestions for the composer.
func (s *PostService) SuggestMentions(ctx context.Context, viewerID, q string, limit, offset int) ([]model.UserSummary, error) {
return s.mentions.Suggest(ctx, viewerID, q, limit, offset)
}
// Mentions lists the users tagged on a post.
func (s *PostService) Mentions(ctx context.Context, postID string) ([]model.UserSummary, error) {
return s.mentions.ListForPost(ctx, postID)
}
// AddMention / RemoveMention edit a post's tags — author only.
func (s *PostService) AddMention(ctx context.Context, viewerID, postID, targetUserID string) error {
if err := s.requireAuthor(ctx, viewerID, postID); err != nil {
return err
}
if err := s.mentions.Add(ctx, postID, targetUserID); err != nil {
return err
}
if s.notif != nil {
go s.notif.NotifyPostMention(context.Background(), targetUserID, viewerID, postID)
}
return nil
}
// notifyMentions notifies each freshly-tagged user (deduped, never the author).
func (s *PostService) notifyMentions(ctx context.Context, postID, authorID string, userIDs []string) {
if s.notif == nil {
return
}
seen := make(map[string]bool, len(userIDs))
for _, uid := range userIDs {
if uid == "" || uid == authorID || seen[uid] {
continue
}
seen[uid] = true
s.notif.NotifyPostMention(ctx, uid, authorID, postID)
}
}
func (s *PostService) RemoveMention(ctx context.Context, viewerID, postID, targetUserID string) error {
if err := s.requireAuthor(ctx, viewerID, postID); err != nil {
return err
}
return s.mentions.Remove(ctx, postID, targetUserID)
}
func (s *PostService) requireAuthor(ctx context.Context, viewerID, postID string) error {
p, err := s.posts.FindByID(ctx, postID)
if err != nil {
return err
}
if p.UserID != viewerID {
return ErrForbidden
}
return nil
}
// Like / Unlike toggle the caller's like on a post and return the effective
// total (likes_count + pending Redis delta).
func (s *PostService) Like(ctx context.Context, userID, postID string) (int, error) {
@@ -256,6 +435,69 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
return nil, 0, err
}
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
return nil, 0, err
}
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
return nil, 0, err
}
return posts, total, nil
}
// ListMineKeyset is the owner's scroll feed via keyset pagination (posts older
// than the cursor, newest-first). Same hydration as ListMine; no total.
func (s *PostService) ListMineKeyset(ctx context.Context, userID string, beforeAt time.Time, beforeID string, limit int) ([]model.Post, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
posts, err := s.posts.ListByUserKeyset(ctx, userID, beforeAt, beforeID, limit)
if err != nil {
return nil, err
}
for i := range posts {
if posts[i].ResharedFromPostID != nil {
posts[i].ViewerHasReshared = true
}
}
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
return nil, err
}
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
return nil, err
}
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
return nil, err
}
return posts, nil
}
// ListHidden is the owner's "Hidden" tab — their hidden posts only, hydrated
// like ListMine so the cards render fully and the menu offers Unhide.
func (s *PostService) ListHidden(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
if offset < 0 {
offset = 0
}
posts, total, err := s.posts.ListHiddenByUser(ctx, userID, limit, offset)
if err != nil {
return nil, 0, err
}
for i := range posts {
if posts[i].ResharedFromPostID != nil {
posts[i].ViewerHasReshared = true
}
}
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
return nil, 0, err
}
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
return nil, 0, err
}
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
return nil, 0, err
}
return posts, total, nil
}
@@ -296,7 +538,7 @@ func (s *BookmarkService) Create(ctx context.Context, userID, sourceURL, sourceT
if err != nil {
return nil, err
}
if err := EnforceBookmarkLimit(u.Plan, current); err != nil {
if err := EnforceBookmarkLimit(u, current); err != nil {
return nil, err
}
@@ -360,7 +602,7 @@ func (s *CollectionService) Create(ctx context.Context, userID, title string, de
if err != nil {
return nil, err
}
if err := EnforceCollectionLimit(u.Plan, isPublic, current); err != nil {
if err := EnforceCollectionLimit(u, isPublic, current); err != nil {
return nil, err
}
return s.collections.Create(ctx, userID, title, description, coverURL, isPublic)
@@ -386,7 +628,7 @@ func (s *CollectionService) Update(ctx context.Context, id, userID string, title
if err != nil {
return nil, err
}
if err := EnforceCollectionLimit(u.Plan, *isPublic, current); err != nil {
if err := EnforceCollectionLimit(u, *isPublic, current); err != nil {
return nil, err
}
}
@@ -475,6 +717,18 @@ func (s *CollectionService) Get(ctx context.Context, id, viewerID string) (*mode
if err != nil {
return nil, nil, err
}
// Hidden posts never appear in PUBLIC collections. Private collections are
// owner-only (gated above) and keep hidden posts so the owner can still
// see and manage them there.
if c.IsPublic {
visible := make([]model.Post, 0, len(posts))
for _, p := range posts {
if !p.IsHidden {
visible = append(visible, p)
}
}
posts = visible
}
// PostsIn returns posts without images (single-table boundary). Hydrate
// them in one batched query so callers — and the post cards rendered
// inside collection carousels — can show the post's first image as
+89
View File
@@ -0,0 +1,89 @@
package service
// Legacy IANA timezone aliases → canonical names.
//
// Browsers report Intl.DateTimeFormat().resolvedOptions().timeZone, and the
// ICU/CLDR data they ship still uses several LEGACY ids as its canonical
// form — Safari and Chrome genuinely say "Asia/Calcutta", not "Asia/Kolkata".
// Go's time.LoadLocation resolves these aliases, so they pass validation and
// get stored; but a Postgres built against tzdata without the "backward"
// links (Debian/Alpine images with the tzdata-legacy split) rejects them in
// AT TIME ZONE with SQLSTATE 22023. Everything we store or hand to SQL goes
// through canonicalTZ first.
var legacyTZ = map[string]string{
// Ids ICU/CLDR still reports as canonical (the ones browsers send).
"Asia/Calcutta": "Asia/Kolkata",
"Asia/Katmandu": "Asia/Kathmandu",
"Asia/Rangoon": "Asia/Yangon",
"Asia/Saigon": "Asia/Ho_Chi_Minh",
"America/Buenos_Aires": "America/Argentina/Buenos_Aires",
"America/Catamarca": "America/Argentina/Catamarca",
"America/Cordoba": "America/Argentina/Cordoba",
"America/Jujuy": "America/Argentina/Jujuy",
"America/Mendoza": "America/Argentina/Mendoza",
"America/Indianapolis": "America/Indiana/Indianapolis",
"America/Louisville": "America/Kentucky/Louisville",
"America/Godthab": "America/Nuuk",
"Atlantic/Faeroe": "Atlantic/Faroe",
"Pacific/Ponape": "Pacific/Pohnpei",
"Pacific/Truk": "Pacific/Chuuk",
"Europe/Kiev": "Europe/Kyiv",
// Other common backward links, in case a zone arrives from an older
// client or was set by hand.
"Asia/Ashkhabad": "Asia/Ashgabat",
"Asia/Chongqing": "Asia/Shanghai",
"Asia/Chungking": "Asia/Shanghai",
"Asia/Dacca": "Asia/Dhaka",
"Asia/Harbin": "Asia/Shanghai",
"Asia/Istanbul": "Europe/Istanbul",
"Asia/Kashgar": "Asia/Urumqi",
"Asia/Macao": "Asia/Macau",
"Asia/Tel_Aviv": "Asia/Jerusalem",
"Asia/Thimbu": "Asia/Thimphu",
"Asia/Ujung_Pandang": "Asia/Makassar",
"Asia/Ulan_Bator": "Asia/Ulaanbaatar",
"Africa/Asmera": "Africa/Asmara",
"America/Coral_Harbour": "America/Atikokan",
"Australia/ACT": "Australia/Sydney",
"Australia/Canberra": "Australia/Sydney",
"Australia/NSW": "Australia/Sydney",
"Australia/North": "Australia/Darwin",
"Australia/Queensland": "Australia/Brisbane",
"Australia/South": "Australia/Adelaide",
"Australia/Tasmania": "Australia/Hobart",
"Australia/Victoria": "Australia/Melbourne",
"Australia/West": "Australia/Perth",
"US/Alaska": "America/Anchorage",
"US/Arizona": "America/Phoenix",
"US/Central": "America/Chicago",
"US/Eastern": "America/New_York",
"US/Hawaii": "Pacific/Honolulu",
"US/Mountain": "America/Denver",
"US/Pacific": "America/Los_Angeles",
"GB": "Europe/London",
"GB-Eire": "Europe/London",
"Eire": "Europe/Dublin",
"Egypt": "Africa/Cairo",
"Hongkong": "Asia/Hong_Kong",
"Iceland": "Atlantic/Reykjavik",
"Israel": "Asia/Jerusalem",
"Japan": "Asia/Tokyo",
"NZ": "Pacific/Auckland",
"Poland": "Europe/Warsaw",
"Portugal": "Europe/Lisbon",
"PRC": "Asia/Shanghai",
"ROC": "Asia/Taipei",
"ROK": "Asia/Seoul",
"Singapore": "Asia/Singapore",
"Turkey": "Europe/Istanbul",
}
// canonicalTZ returns the canonical IANA name for tz, resolving legacy
// aliases; unknown values pass through unchanged.
func canonicalTZ(tz string) string {
if c, ok := legacyTZ[tz]; ok {
return c
}
return tz
}
+424
View File
@@ -0,0 +1,424 @@
// Code generated from the IANA tz database (zone.tab). DO NOT EDIT by hand.
// Maps a canonical IANA timezone to its ISO-3166-1 alpha-2 country code.
package service
var tzCountry = map[string]string{
"Africa/Abidjan": "CI",
"Africa/Accra": "GH",
"Africa/Addis_Ababa": "ET",
"Africa/Algiers": "DZ",
"Africa/Asmara": "ER",
"Africa/Bamako": "ML",
"Africa/Bangui": "CF",
"Africa/Banjul": "GM",
"Africa/Bissau": "GW",
"Africa/Blantyre": "MW",
"Africa/Brazzaville": "CG",
"Africa/Bujumbura": "BI",
"Africa/Cairo": "EG",
"Africa/Casablanca": "MA",
"Africa/Ceuta": "ES",
"Africa/Conakry": "GN",
"Africa/Dakar": "SN",
"Africa/Dar_es_Salaam": "TZ",
"Africa/Djibouti": "DJ",
"Africa/Douala": "CM",
"Africa/El_Aaiun": "EH",
"Africa/Freetown": "SL",
"Africa/Gaborone": "BW",
"Africa/Harare": "ZW",
"Africa/Johannesburg": "ZA",
"Africa/Juba": "SS",
"Africa/Kampala": "UG",
"Africa/Khartoum": "SD",
"Africa/Kigali": "RW",
"Africa/Kinshasa": "CD",
"Africa/Lagos": "NG",
"Africa/Libreville": "GA",
"Africa/Lome": "TG",
"Africa/Luanda": "AO",
"Africa/Lubumbashi": "CD",
"Africa/Lusaka": "ZM",
"Africa/Malabo": "GQ",
"Africa/Maputo": "MZ",
"Africa/Maseru": "LS",
"Africa/Mbabane": "SZ",
"Africa/Mogadishu": "SO",
"Africa/Monrovia": "LR",
"Africa/Nairobi": "KE",
"Africa/Ndjamena": "TD",
"Africa/Niamey": "NE",
"Africa/Nouakchott": "MR",
"Africa/Ouagadougou": "BF",
"Africa/Porto-Novo": "BJ",
"Africa/Sao_Tome": "ST",
"Africa/Tripoli": "LY",
"Africa/Tunis": "TN",
"Africa/Windhoek": "NA",
"America/Adak": "US",
"America/Anchorage": "US",
"America/Anguilla": "AI",
"America/Antigua": "AG",
"America/Araguaina": "BR",
"America/Argentina/Buenos_Aires": "AR",
"America/Argentina/Catamarca": "AR",
"America/Argentina/Cordoba": "AR",
"America/Argentina/Jujuy": "AR",
"America/Argentina/La_Rioja": "AR",
"America/Argentina/Mendoza": "AR",
"America/Argentina/Rio_Gallegos": "AR",
"America/Argentina/Salta": "AR",
"America/Argentina/San_Juan": "AR",
"America/Argentina/San_Luis": "AR",
"America/Argentina/Tucuman": "AR",
"America/Argentina/Ushuaia": "AR",
"America/Aruba": "AW",
"America/Asuncion": "PY",
"America/Atikokan": "CA",
"America/Bahia": "BR",
"America/Bahia_Banderas": "MX",
"America/Barbados": "BB",
"America/Belem": "BR",
"America/Belize": "BZ",
"America/Blanc-Sablon": "CA",
"America/Boa_Vista": "BR",
"America/Bogota": "CO",
"America/Boise": "US",
"America/Cambridge_Bay": "CA",
"America/Campo_Grande": "BR",
"America/Cancun": "MX",
"America/Caracas": "VE",
"America/Cayenne": "GF",
"America/Cayman": "KY",
"America/Chicago": "US",
"America/Chihuahua": "MX",
"America/Ciudad_Juarez": "MX",
"America/Costa_Rica": "CR",
"America/Coyhaique": "CL",
"America/Creston": "CA",
"America/Cuiaba": "BR",
"America/Curacao": "CW",
"America/Danmarkshavn": "GL",
"America/Dawson": "CA",
"America/Dawson_Creek": "CA",
"America/Denver": "US",
"America/Detroit": "US",
"America/Dominica": "DM",
"America/Edmonton": "CA",
"America/Eirunepe": "BR",
"America/El_Salvador": "SV",
"America/Fort_Nelson": "CA",
"America/Fortaleza": "BR",
"America/Glace_Bay": "CA",
"America/Goose_Bay": "CA",
"America/Grand_Turk": "TC",
"America/Grenada": "GD",
"America/Guadeloupe": "GP",
"America/Guatemala": "GT",
"America/Guayaquil": "EC",
"America/Guyana": "GY",
"America/Halifax": "CA",
"America/Havana": "CU",
"America/Hermosillo": "MX",
"America/Indiana/Indianapolis": "US",
"America/Indiana/Knox": "US",
"America/Indiana/Marengo": "US",
"America/Indiana/Petersburg": "US",
"America/Indiana/Tell_City": "US",
"America/Indiana/Vevay": "US",
"America/Indiana/Vincennes": "US",
"America/Indiana/Winamac": "US",
"America/Inuvik": "CA",
"America/Iqaluit": "CA",
"America/Jamaica": "JM",
"America/Juneau": "US",
"America/Kentucky/Louisville": "US",
"America/Kentucky/Monticello": "US",
"America/Kralendijk": "BQ",
"America/La_Paz": "BO",
"America/Lima": "PE",
"America/Los_Angeles": "US",
"America/Lower_Princes": "SX",
"America/Maceio": "BR",
"America/Managua": "NI",
"America/Manaus": "BR",
"America/Marigot": "MF",
"America/Martinique": "MQ",
"America/Matamoros": "MX",
"America/Mazatlan": "MX",
"America/Menominee": "US",
"America/Merida": "MX",
"America/Metlakatla": "US",
"America/Mexico_City": "MX",
"America/Miquelon": "PM",
"America/Moncton": "CA",
"America/Monterrey": "MX",
"America/Montevideo": "UY",
"America/Montserrat": "MS",
"America/Nassau": "BS",
"America/New_York": "US",
"America/Nome": "US",
"America/Noronha": "BR",
"America/North_Dakota/Beulah": "US",
"America/North_Dakota/Center": "US",
"America/North_Dakota/New_Salem": "US",
"America/Nuuk": "GL",
"America/Ojinaga": "MX",
"America/Panama": "PA",
"America/Paramaribo": "SR",
"America/Phoenix": "US",
"America/Port-au-Prince": "HT",
"America/Port_of_Spain": "TT",
"America/Porto_Velho": "BR",
"America/Puerto_Rico": "PR",
"America/Punta_Arenas": "CL",
"America/Rankin_Inlet": "CA",
"America/Recife": "BR",
"America/Regina": "CA",
"America/Resolute": "CA",
"America/Rio_Branco": "BR",
"America/Santarem": "BR",
"America/Santiago": "CL",
"America/Santo_Domingo": "DO",
"America/Sao_Paulo": "BR",
"America/Scoresbysund": "GL",
"America/Sitka": "US",
"America/St_Barthelemy": "BL",
"America/St_Johns": "CA",
"America/St_Kitts": "KN",
"America/St_Lucia": "LC",
"America/St_Thomas": "VI",
"America/St_Vincent": "VC",
"America/Swift_Current": "CA",
"America/Tegucigalpa": "HN",
"America/Thule": "GL",
"America/Tijuana": "MX",
"America/Toronto": "CA",
"America/Tortola": "VG",
"America/Vancouver": "CA",
"America/Whitehorse": "CA",
"America/Winnipeg": "CA",
"America/Yakutat": "US",
"Antarctica/Casey": "AQ",
"Antarctica/Davis": "AQ",
"Antarctica/DumontDUrville": "AQ",
"Antarctica/Macquarie": "AU",
"Antarctica/Mawson": "AQ",
"Antarctica/McMurdo": "AQ",
"Antarctica/Palmer": "AQ",
"Antarctica/Rothera": "AQ",
"Antarctica/Syowa": "AQ",
"Antarctica/Troll": "AQ",
"Antarctica/Vostok": "AQ",
"Arctic/Longyearbyen": "SJ",
"Asia/Aden": "YE",
"Asia/Almaty": "KZ",
"Asia/Amman": "JO",
"Asia/Anadyr": "RU",
"Asia/Aqtau": "KZ",
"Asia/Aqtobe": "KZ",
"Asia/Ashgabat": "TM",
"Asia/Atyrau": "KZ",
"Asia/Baghdad": "IQ",
"Asia/Bahrain": "BH",
"Asia/Baku": "AZ",
"Asia/Bangkok": "TH",
"Asia/Barnaul": "RU",
"Asia/Beirut": "LB",
"Asia/Bishkek": "KG",
"Asia/Brunei": "BN",
"Asia/Chita": "RU",
"Asia/Colombo": "LK",
"Asia/Damascus": "SY",
"Asia/Dhaka": "BD",
"Asia/Dili": "TL",
"Asia/Dubai": "AE",
"Asia/Dushanbe": "TJ",
"Asia/Famagusta": "CY",
"Asia/Gaza": "PS",
"Asia/Hebron": "PS",
"Asia/Ho_Chi_Minh": "VN",
"Asia/Hong_Kong": "HK",
"Asia/Hovd": "MN",
"Asia/Irkutsk": "RU",
"Asia/Jakarta": "ID",
"Asia/Jayapura": "ID",
"Asia/Jerusalem": "IL",
"Asia/Kabul": "AF",
"Asia/Kamchatka": "RU",
"Asia/Karachi": "PK",
"Asia/Kathmandu": "NP",
"Asia/Khandyga": "RU",
"Asia/Kolkata": "IN",
"Asia/Krasnoyarsk": "RU",
"Asia/Kuala_Lumpur": "MY",
"Asia/Kuching": "MY",
"Asia/Kuwait": "KW",
"Asia/Macau": "MO",
"Asia/Magadan": "RU",
"Asia/Makassar": "ID",
"Asia/Manila": "PH",
"Asia/Muscat": "OM",
"Asia/Nicosia": "CY",
"Asia/Novokuznetsk": "RU",
"Asia/Novosibirsk": "RU",
"Asia/Omsk": "RU",
"Asia/Oral": "KZ",
"Asia/Phnom_Penh": "KH",
"Asia/Pontianak": "ID",
"Asia/Pyongyang": "KP",
"Asia/Qatar": "QA",
"Asia/Qostanay": "KZ",
"Asia/Qyzylorda": "KZ",
"Asia/Riyadh": "SA",
"Asia/Sakhalin": "RU",
"Asia/Samarkand": "UZ",
"Asia/Seoul": "KR",
"Asia/Shanghai": "CN",
"Asia/Singapore": "SG",
"Asia/Srednekolymsk": "RU",
"Asia/Taipei": "TW",
"Asia/Tashkent": "UZ",
"Asia/Tbilisi": "GE",
"Asia/Tehran": "IR",
"Asia/Thimphu": "BT",
"Asia/Tokyo": "JP",
"Asia/Tomsk": "RU",
"Asia/Ulaanbaatar": "MN",
"Asia/Urumqi": "CN",
"Asia/Ust-Nera": "RU",
"Asia/Vientiane": "LA",
"Asia/Vladivostok": "RU",
"Asia/Yakutsk": "RU",
"Asia/Yangon": "MM",
"Asia/Yekaterinburg": "RU",
"Asia/Yerevan": "AM",
"Atlantic/Azores": "PT",
"Atlantic/Bermuda": "BM",
"Atlantic/Canary": "ES",
"Atlantic/Cape_Verde": "CV",
"Atlantic/Faroe": "FO",
"Atlantic/Madeira": "PT",
"Atlantic/Reykjavik": "IS",
"Atlantic/South_Georgia": "GS",
"Atlantic/St_Helena": "SH",
"Atlantic/Stanley": "FK",
"Australia/Adelaide": "AU",
"Australia/Brisbane": "AU",
"Australia/Broken_Hill": "AU",
"Australia/Darwin": "AU",
"Australia/Eucla": "AU",
"Australia/Hobart": "AU",
"Australia/Lindeman": "AU",
"Australia/Lord_Howe": "AU",
"Australia/Melbourne": "AU",
"Australia/Perth": "AU",
"Australia/Sydney": "AU",
"Europe/Amsterdam": "NL",
"Europe/Andorra": "AD",
"Europe/Astrakhan": "RU",
"Europe/Athens": "GR",
"Europe/Belgrade": "RS",
"Europe/Berlin": "DE",
"Europe/Bratislava": "SK",
"Europe/Brussels": "BE",
"Europe/Bucharest": "RO",
"Europe/Budapest": "HU",
"Europe/Busingen": "DE",
"Europe/Chisinau": "MD",
"Europe/Copenhagen": "DK",
"Europe/Dublin": "IE",
"Europe/Gibraltar": "GI",
"Europe/Guernsey": "GG",
"Europe/Helsinki": "FI",
"Europe/Isle_of_Man": "IM",
"Europe/Istanbul": "TR",
"Europe/Jersey": "JE",
"Europe/Kaliningrad": "RU",
"Europe/Kirov": "RU",
"Europe/Kyiv": "UA",
"Europe/Lisbon": "PT",
"Europe/Ljubljana": "SI",
"Europe/London": "GB",
"Europe/Luxembourg": "LU",
"Europe/Madrid": "ES",
"Europe/Malta": "MT",
"Europe/Mariehamn": "AX",
"Europe/Minsk": "BY",
"Europe/Monaco": "MC",
"Europe/Moscow": "RU",
"Europe/Oslo": "NO",
"Europe/Paris": "FR",
"Europe/Podgorica": "ME",
"Europe/Prague": "CZ",
"Europe/Riga": "LV",
"Europe/Rome": "IT",
"Europe/Samara": "RU",
"Europe/San_Marino": "SM",
"Europe/Sarajevo": "BA",
"Europe/Saratov": "RU",
"Europe/Simferopol": "UA",
"Europe/Skopje": "MK",
"Europe/Sofia": "BG",
"Europe/Stockholm": "SE",
"Europe/Tallinn": "EE",
"Europe/Tirane": "AL",
"Europe/Ulyanovsk": "RU",
"Europe/Vaduz": "LI",
"Europe/Vatican": "VA",
"Europe/Vienna": "AT",
"Europe/Vilnius": "LT",
"Europe/Volgograd": "RU",
"Europe/Warsaw": "PL",
"Europe/Zagreb": "HR",
"Europe/Zurich": "CH",
"Indian/Antananarivo": "MG",
"Indian/Chagos": "IO",
"Indian/Christmas": "CX",
"Indian/Cocos": "CC",
"Indian/Comoro": "KM",
"Indian/Kerguelen": "TF",
"Indian/Mahe": "SC",
"Indian/Maldives": "MV",
"Indian/Mauritius": "MU",
"Indian/Mayotte": "YT",
"Indian/Reunion": "RE",
"Pacific/Apia": "WS",
"Pacific/Auckland": "NZ",
"Pacific/Bougainville": "PG",
"Pacific/Chatham": "NZ",
"Pacific/Chuuk": "FM",
"Pacific/Easter": "CL",
"Pacific/Efate": "VU",
"Pacific/Fakaofo": "TK",
"Pacific/Fiji": "FJ",
"Pacific/Funafuti": "TV",
"Pacific/Galapagos": "EC",
"Pacific/Gambier": "PF",
"Pacific/Guadalcanal": "SB",
"Pacific/Guam": "GU",
"Pacific/Honolulu": "US",
"Pacific/Kanton": "KI",
"Pacific/Kiritimati": "KI",
"Pacific/Kosrae": "FM",
"Pacific/Kwajalein": "MH",
"Pacific/Majuro": "MH",
"Pacific/Marquesas": "PF",
"Pacific/Midway": "UM",
"Pacific/Nauru": "NR",
"Pacific/Niue": "NU",
"Pacific/Norfolk": "NF",
"Pacific/Noumea": "NC",
"Pacific/Pago_Pago": "AS",
"Pacific/Palau": "PW",
"Pacific/Pitcairn": "PN",
"Pacific/Pohnpei": "FM",
"Pacific/Port_Moresby": "PG",
"Pacific/Rarotonga": "CK",
"Pacific/Saipan": "MP",
"Pacific/Tahiti": "PF",
"Pacific/Tarawa": "KI",
"Pacific/Tongatapu": "TO",
"Pacific/Wake": "UM",
"Pacific/Wallis": "WF",
}
+21 -3
View File
@@ -9,6 +9,7 @@ import (
"path"
"strings"
"time"
"unicode/utf8"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
@@ -43,6 +44,7 @@ type UpdateProfileInput struct {
Name *string
AvatarURL *string
Handle *string
Bio *string
Country *string
Sampradaya *string
Gender *string
@@ -53,6 +55,10 @@ type UpdateProfileInput struct {
PeakActiveTime *string
}
// MaxBioRunes caps the profile bio. Counted in runes so the limit means the
// same thing in every script. Keep in sync with the editor's counter.
const MaxBioRunes = 200
// validGenders is the accepted set; "" clears the field.
var validGenders = map[string]bool{"male": true, "female": true, "other": true}
@@ -87,6 +93,17 @@ func (s *UserService) UpdateProfile(ctx context.Context, id string, in UpdatePro
}
p.Handle = &clean
}
if in.Bio != nil {
// "" clears. Counted in runes, not bytes, so a devotee writing in
// Devanāgarī gets the same 200 characters as a Latin-script writer
// (len() would cut them off after ~66). Collapse the CRLF that
// Windows browsers submit so the count matches what the editor shows.
b := strings.TrimSpace(strings.ReplaceAll(*in.Bio, "\r\n", "\n"))
if utf8.RuneCountInString(b) > MaxBioRunes {
return nil, fmt.Errorf("bio too long (max %d characters)", MaxBioRunes)
}
p.Bio = &b
}
if in.Country != nil {
// "" clears; otherwise a 2-letter ISO-3166-1 alpha-2 code (the
// frontend dropdown only ever sends valid codes).
@@ -158,10 +175,11 @@ func normalizeClock(raw string) (string, error) {
return t, nil
}
// Search returns users matching the query (handle or name substring).
// Search returns the `offset`/`limit` page of users matching the query
// (handle or name substring) plus the total match count for pagination.
// Used by the public user-search endpoint on the /search page.
func (s *UserService) Search(ctx context.Context, q string, limit int) ([]model.User, error) {
return s.users.Search(ctx, q, limit)
func (s *UserService) Search(ctx context.Context, q string, limit, offset int) ([]model.User, int, error) {
return s.users.Search(ctx, q, limit, offset)
}
// GetReadingPrefs returns the user's reading prefs blob (opaque JSON).
+235
View File
@@ -0,0 +1,235 @@
// Package service — VaniService powers "Vāṇī of the Day": one piece of content
// (a prose chapter, a verse, or a transcript/lecture) surfaced on the home page
// each day. The pick is chosen once per UTC day and reused for everyone; the
// picker avoids anything served in the last 90 days.
package service
import (
"context"
"fmt"
"math/rand"
"regexp"
"strings"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
const vaniNoRepeatDays = 90
type VaniService struct {
repo *repository.VaniRepo
logger *zap.Logger
}
func NewVaniService(repo *repository.VaniRepo, logger *zap.Logger) *VaniService {
return &VaniService{repo: repo, logger: logger}
}
// Today returns the current day's pick, choosing (and persisting) one on the
// first call of the day. Returns (nil, nil) only if there's genuinely no
// content to pick.
func (s *VaniService) Today(ctx context.Context) (*model.VaniPayload, error) {
now := time.Now().UTC()
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
if existing, err := s.repo.GetForDay(ctx, day); err != nil {
return nil, err
} else if existing != nil {
return &existing.Payload, nil
}
// Build the 90-day exclusion set, bucketed by kind.
recent, err := s.repo.RecentRefIDs(ctx, day.AddDate(0, 0, -vaniNoRepeatDays))
if err != nil {
return nil, err
}
exclude := map[string][]string{}
for _, rid := range recent {
if kind, hex, ok := splitRef(rid); ok {
exclude[kind] = append(exclude[kind], hex)
}
}
pick := s.choose(ctx, exclude, day)
if pick == nil {
// Everything in some kind exhausted the 90-day window? Relax it.
pick = s.choose(ctx, map[string][]string{}, day)
}
if pick == nil {
return nil, nil
}
if err := s.repo.Insert(ctx, pick); err != nil {
s.logger.Warn("vani: insert failed", zap.Error(err))
}
// Re-read so a concurrent writer's row (ON CONFLICT DO NOTHING) wins
// consistently for everyone.
if stored, err := s.repo.GetForDay(ctx, day); err == nil && stored != nil {
return &stored.Payload, nil
}
return &pick.Payload, nil
}
// choose tries the three content kinds in random order and returns the first
// successful sample, so each day's pick varies across content types.
func (s *VaniService) choose(ctx context.Context, exclude map[string][]string, day time.Time) *model.VaniPick {
kinds := []string{"verse", "prose", "transcript"}
rand.Shuffle(len(kinds), func(i, j int) { kinds[i], kinds[j] = kinds[j], kinds[i] })
for _, kind := range kinds {
switch kind {
case "verse":
if v, err := s.repo.SampleVerse(ctx, exclude["verse"]); err == nil && v != nil {
return versePick(v, day)
}
case "prose":
if p, err := s.repo.SampleProse(ctx, exclude["prose"]); err == nil && p != nil {
return prosePick(p, s.repo.BookTitle(ctx, p.Book), day)
}
case "transcript":
if t, err := s.repo.SampleTranscript(ctx, exclude["transcript"]); err == nil && t != nil {
return transcriptPick(t, day)
}
}
}
return nil
}
// --- Payload builders ---
func versePick(v *repository.VerseDoc, day time.Time) *model.VaniPick {
title := v.Reference
if title == "" {
title = fmt.Sprintf("%s %d.%d", strings.ToUpper(v.Book), v.Chapter, v.Verse)
}
return &model.VaniPick{
Day: day,
Kind: "verse",
RefID: "verse:" + v.ID.Hex(),
Payload: model.VaniPayload{
Kind: "verse",
Label: "Verse",
Title: title,
Subtitle: excerpt(v.Translation, 140),
Href: verseURL(v.Book, v.Division, v.Chapter, v.Verse),
BookTitle: v.BookTitle,
},
}
}
func prosePick(p *repository.ProseChapterDoc, bookTitle string, day time.Time) *model.VaniPick {
first := ""
if len(p.Paragraphs) > 0 {
first = p.Paragraphs[0]
}
return &model.VaniPick{
Day: day,
Kind: "prose",
RefID: "prose:" + p.ID.Hex(),
Payload: model.VaniPayload{
Kind: "prose",
Label: "Chapter",
Title: p.Title,
Subtitle: excerpt(first, 140),
Href: proseURL(p.Book, p.Volume, p.Type, p.Slug),
BookTitle: bookTitle,
},
}
}
func transcriptPick(t *repository.TranscriptionDoc, day time.Time) *model.VaniPick {
label := "Lecture"
if t.Type == "conversation" {
label = "Conversation"
}
date := ""
if t.Date != nil {
date = *t.Date
}
return &model.VaniPick{
Day: day,
Kind: "transcript",
RefID: "transcript:" + t.ID.Hex(),
Payload: model.VaniPayload{
Kind: "transcript",
Label: label,
Title: t.Name,
Href: "/transcriptions/" + t.Slug,
Date: date,
},
}
}
// --- URL builders (mirror cmd/indexer buildVerseURL / prose URL logic) ---
func verseURL(book string, division *string, chapter, verse int) string {
switch book {
case "bg":
return fmt.Sprintf("/books/bg/%d/%d", chapter, verse)
case "sb":
if division != nil {
return fmt.Sprintf("/books/sb/%s/%d/%d", *division, chapter, verse)
}
return fmt.Sprintf("/books/sb/%d/%d", chapter, verse)
case "cc":
if division != nil {
return fmt.Sprintf("/books/cc/%s/%d/%d", *division, chapter, verse)
}
return fmt.Sprintf("/books/cc/%d/%d", chapter, verse)
case "sb1962":
return fmt.Sprintf("/books/sb1962/%d/%d", chapter, verse)
case "iso":
return fmt.Sprintf("/books/iso/%d", chapter)
case "noi":
return fmt.Sprintf("/books/noi/%d", chapter)
case "bs":
return fmt.Sprintf("/books/bs/5/%d", verse)
case "mm":
return fmt.Sprintf("/books/mm/%d/%d", chapter, verse)
case "nbs":
return fmt.Sprintf("/books/nbs/%d/%d", chapter, verse)
default:
return fmt.Sprintf("/books/%s/%d/%d", book, chapter, verse)
}
}
func proseURL(book string, volume int, docType, slug string) string {
switch {
case book == "kb":
return fmt.Sprintf("/books/kb/%d/%s", volume, slug)
case docType == "chapter-summary":
if parts := strings.SplitN(slug, "-", 2); len(parts) == 2 {
return fmt.Sprintf("/books/%s/%s/%s", book, parts[0], parts[1])
}
return fmt.Sprintf("/books/%s/%s", book, slug)
default:
return fmt.Sprintf("/books/%s/%s", book, slug)
}
}
// --- helpers ---
func splitRef(ref string) (kind, hex string, ok bool) {
if i := strings.IndexByte(ref, ':'); i > 0 {
return ref[:i], ref[i+1:], true
}
return "", "", false
}
var htmlTagRe = regexp.MustCompile(`<[^>]*>`)
var wsRe = regexp.MustCompile(`\s+`)
// excerpt strips HTML, collapses whitespace, and truncates to n runes with an
// ellipsis — a clean one-line teaser for the CTA.
func excerpt(html string, n int) string {
s := htmlTagRe.ReplaceAllString(html, "")
s = strings.TrimSpace(wsRe.ReplaceAllString(s, " "))
r := []rune(s)
if len(r) <= n {
return s
}
return strings.TrimSpace(string(r[:n])) + "…"
}
@@ -0,0 +1,2 @@
ALTER TABLE users DROP COLUMN IF EXISTS like_emails_enabled;
ALTER TABLE notifications DROP COLUMN IF EXISTS like_email_sent_at;
+9
View File
@@ -0,0 +1,9 @@
-- Aggregated like emails.
--
-- like_email_sent_at throttles the per-post like email: once an aggregated
-- "X and N others liked your post" email goes out, further likes within the
-- throttle window only update the in-app/push aggregate, not a fresh email.
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS like_email_sent_at TIMESTAMPTZ;
-- Per-user opt-out for like emails (push + in-app are unaffected).
ALTER TABLE users ADD COLUMN IF NOT EXISTS like_emails_enabled BOOLEAN NOT NULL DEFAULT TRUE;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS post_mentions;
@@ -0,0 +1,14 @@
-- Post mentions — users tagged on a post (separate from the locked caption).
-- Membership table; the count is small and edited rarely, so it's derived at
-- read time (no denormalized counter / worker needed).
CREATE TABLE IF NOT EXISTS post_mentions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (post_id, user_id)
);
-- List a post's mentions (insertion order) + hydrate mention counts across a feed.
CREATE INDEX IF NOT EXISTS post_mentions_post_idx ON post_mentions (post_id, created_at);
CREATE INDEX IF NOT EXISTS post_mentions_user_idx ON post_mentions (user_id);
@@ -0,0 +1,3 @@
-- The legacy users columns were dual-written the whole time, so dropping the
-- table loses no opt-out state that the old code paths read.
DROP TABLE IF EXISTS notification_prefs;
+41
View File
@@ -0,0 +1,41 @@
-- Per-event × per-channel notification preferences.
--
-- One row per (user, kind, channel) the user has turned OFF. Absence of a row
-- means enabled — the table stores opt-outs only, so new kinds/channels are
-- enabled for everyone with no backfill, and existing users keep receiving
-- everything they receive today.
--
-- `kind` is the notifications.kind machine string (follow_request, post_like,
-- chant_reminder, …); the Settings UI groups kinds into coarser categories
-- but storage stays kind-level so backfilled opt-outs map exactly.
CREATE TABLE IF NOT EXISTS notification_prefs (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
channel TEXT NOT NULL CHECK (channel IN ('in_app', 'email', 'push')),
enabled BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, kind, channel)
);
-- Backfill the existing like-email opt-out at exactly its old granularity:
-- post_like × email only (NOT the whole post-interactions category, so these
-- users don't accidentally lose anything else).
INSERT INTO notification_prefs (user_id, kind, channel, enabled)
SELECT id, 'post_like', 'email', FALSE
FROM users WHERE like_emails_enabled = FALSE
ON CONFLICT DO NOTHING;
-- Backfill the reminder opt-out: the old toggle silenced all channels at
-- once, so it maps to every reminder kind × every channel.
INSERT INTO notification_prefs (user_id, kind, channel, enabled)
SELECT u.id, k.kind, c.channel, FALSE
FROM users u
CROSS JOIN (VALUES ('chant_reminder'), ('quote_reminder'), ('chant_incomplete_reminder')) AS k(kind)
CROSS JOIN (VALUES ('in_app'), ('email'), ('push')) AS c(channel)
WHERE u.sadhna_reminders_enabled = FALSE
ON CONFLICT DO NOTHING;
-- users.like_emails_enabled and users.sadhna_reminders_enabled stay for now:
-- the prefs endpoints dual-write them so an old frontend (or a rollback)
-- keeps working. They get dropped in a later migration once the new
-- endpoints are fully rolled out.
@@ -0,0 +1,3 @@
-- No-op: the canonical names are valid everywhere the aliases were, so
-- there's nothing to restore (and the original alias per row isn't known).
SELECT 1;
@@ -0,0 +1,74 @@
-- Normalize legacy IANA timezone aliases stored in users.timezone.
--
-- Browsers (ICU/CLDR) report legacy ids — Safari sends "Asia/Calcutta", not
-- "Asia/Kolkata" — and Go's validation accepts them, so they were stored
-- verbatim. A Postgres built against tzdata without the "backward" links
-- rejects those names in AT TIME ZONE (SQLSTATE 22023), 500-ing the sādhana
-- dashboard. The Go side now canonicalizes on read and write (see
-- internal/service/timezone.go); this backfills the rows written before.
UPDATE users SET timezone = m.canonical
FROM (VALUES
('Asia/Calcutta', 'Asia/Kolkata'),
('Asia/Katmandu', 'Asia/Kathmandu'),
('Asia/Rangoon', 'Asia/Yangon'),
('Asia/Saigon', 'Asia/Ho_Chi_Minh'),
('America/Buenos_Aires', 'America/Argentina/Buenos_Aires'),
('America/Catamarca', 'America/Argentina/Catamarca'),
('America/Cordoba', 'America/Argentina/Cordoba'),
('America/Jujuy', 'America/Argentina/Jujuy'),
('America/Mendoza', 'America/Argentina/Mendoza'),
('America/Indianapolis', 'America/Indiana/Indianapolis'),
('America/Louisville', 'America/Kentucky/Louisville'),
('America/Godthab', 'America/Nuuk'),
('Atlantic/Faeroe', 'Atlantic/Faroe'),
('Pacific/Ponape', 'Pacific/Pohnpei'),
('Pacific/Truk', 'Pacific/Chuuk'),
('Europe/Kiev', 'Europe/Kyiv'),
('Asia/Ashkhabad', 'Asia/Ashgabat'),
('Asia/Chongqing', 'Asia/Shanghai'),
('Asia/Chungking', 'Asia/Shanghai'),
('Asia/Dacca', 'Asia/Dhaka'),
('Asia/Harbin', 'Asia/Shanghai'),
('Asia/Istanbul', 'Europe/Istanbul'),
('Asia/Kashgar', 'Asia/Urumqi'),
('Asia/Macao', 'Asia/Macau'),
('Asia/Tel_Aviv', 'Asia/Jerusalem'),
('Asia/Thimbu', 'Asia/Thimphu'),
('Asia/Ujung_Pandang', 'Asia/Makassar'),
('Asia/Ulan_Bator', 'Asia/Ulaanbaatar'),
('Africa/Asmera', 'Africa/Asmara'),
('America/Coral_Harbour','America/Atikokan'),
('Australia/ACT', 'Australia/Sydney'),
('Australia/Canberra', 'Australia/Sydney'),
('Australia/NSW', 'Australia/Sydney'),
('Australia/North', 'Australia/Darwin'),
('Australia/Queensland', 'Australia/Brisbane'),
('Australia/South', 'Australia/Adelaide'),
('Australia/Tasmania', 'Australia/Hobart'),
('Australia/Victoria', 'Australia/Melbourne'),
('Australia/West', 'Australia/Perth'),
('US/Alaska', 'America/Anchorage'),
('US/Arizona', 'America/Phoenix'),
('US/Central', 'America/Chicago'),
('US/Eastern', 'America/New_York'),
('US/Hawaii', 'Pacific/Honolulu'),
('US/Mountain', 'America/Denver'),
('US/Pacific', 'America/Los_Angeles'),
('GB', 'Europe/London'),
('GB-Eire', 'Europe/London'),
('Eire', 'Europe/Dublin'),
('Egypt', 'Africa/Cairo'),
('Hongkong', 'Asia/Hong_Kong'),
('Iceland', 'Atlantic/Reykjavik'),
('Israel', 'Asia/Jerusalem'),
('Japan', 'Asia/Tokyo'),
('NZ', 'Pacific/Auckland'),
('Poland', 'Europe/Warsaw'),
('Portugal', 'Europe/Lisbon'),
('PRC', 'Asia/Shanghai'),
('ROC', 'Asia/Taipei'),
('ROK', 'Asia/Seoul'),
('Singapore', 'Asia/Singapore'),
('Turkey', 'Europe/Istanbul')
) AS m(alias, canonical)
WHERE users.timezone = m.alias;
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS comment_reports;
DROP TABLE IF EXISTS comment_likes;
DROP TABLE IF EXISTS post_comments;
@@ -0,0 +1,62 @@
-- Post comments — two-level threads, likes, pinning, reports.
--
-- Threading is deliberately ONE level deep: `parent_id` is NULL for a
-- top-level comment and otherwise points at a TOP-LEVEL comment (the thread
-- root). When a user replies to a reply, the service flattens the new comment
-- onto the same root and records `reply_to_user_id` so the UI can render the
-- "@handle" it was addressed to — so every reply lives under one thread.
-- ON DELETE CASCADE on parent_id means deleting a root removes its whole thread.
CREATE TABLE IF NOT EXISTS post_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id UUID REFERENCES post_comments(id) ON DELETE CASCADE,
-- The user a reply is addressed to (for the "@handle" prefix). NULL on
-- top-level comments. SET NULL if that user is later deleted.
reply_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
body TEXT NOT NULL,
-- Denormalized like counter (the UI shows only the count; comment_likes
-- keeps the relations). Kept in sync on like/unlike in the same txn — comment
-- likes are far lower-volume than post likes, so no Redis batching here.
likes_count INTEGER NOT NULL DEFAULT 0,
pinned_at TIMESTAMPTZ, -- non-null = pinned (top-level only)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Dedup: a user can't post the identical text twice in the same scope (post +
-- thread). COALESCE folds top-level (NULL parent) onto a fixed sentinel so the
-- index covers it; md5(body) keeps the key small for long comments.
CREATE UNIQUE INDEX IF NOT EXISTS post_comments_dedup
ON post_comments (post_id, user_id, COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), md5(body));
-- Listing a post's top-level comments, and a thread's replies (oldest first).
CREATE INDEX IF NOT EXISTS post_comments_post_idx ON post_comments (post_id, created_at);
CREATE INDEX IF NOT EXISTS post_comments_parent_idx ON post_comments (parent_id, created_at);
-- At most one pinned comment per post.
CREATE UNIQUE INDEX IF NOT EXISTS post_comments_one_pin
ON post_comments (post_id) WHERE pinned_at IS NOT NULL;
-- Comment likes — membership rows backing the displayed count. Idempotent via PK.
CREATE TABLE IF NOT EXISTS comment_likes (
comment_id UUID NOT NULL REFERENCES post_comments(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (comment_id, user_id)
);
-- "Which comments has this viewer liked?" — viewer-state hydration across a thread.
CREATE INDEX IF NOT EXISTS comment_likes_user_idx ON comment_likes (user_id);
-- Comment reports — mirrors post_reports, keyed to a comment.
CREATE TABLE IF NOT EXISTS comment_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reporter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
comment_id UUID NOT NULL REFERENCES post_comments(id) ON DELETE CASCADE,
category VARCHAR(32) NOT NULL,
reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS comment_reports_unresolved_idx
ON comment_reports (created_at DESC) WHERE resolved_at IS NULL;
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS posts_user_created_visible_idx;
ALTER TABLE posts DROP COLUMN IF EXISTS is_hidden;
+11
View File
@@ -0,0 +1,11 @@
-- Posts can be hidden from timelines (profile feed as seen by others, the
-- global home feed) and from public collections by their owner. Hidden posts
-- still appear to the owner on their own profile and in private collections.
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS is_hidden BOOLEAN NOT NULL DEFAULT FALSE;
-- Partial index keeps the "visible posts, newest first" timeline/feed scans
-- cheap by excluding hidden rows from the index entirely.
CREATE INDEX IF NOT EXISTS posts_user_created_visible_idx
ON posts (user_id, created_at DESC)
WHERE is_hidden = FALSE;
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE feedback DROP COLUMN IF EXISTS reply_message;
ALTER TABLE feedback DROP COLUMN IF EXISTS replied_at;
ALTER TABLE notifications DROP COLUMN IF EXISTS body;
+15
View File
@@ -0,0 +1,15 @@
-- Admin panel support. All additive — existing columns/queries are untouched,
-- so the public API and the existing frontend keep working unchanged.
-- Feedback replies: an admin can write a reply that gets emailed to the
-- submitter. reply_message holds the text, replied_at stamps when it was sent
-- (one-way, like resolved_at). Replying also resolves the row.
ALTER TABLE feedback
ADD COLUMN IF NOT EXISTS reply_message TEXT,
ADD COLUMN IF NOT EXISTS replied_at TIMESTAMPTZ;
-- Admin → user direct messages reuse the notifications feed. Event-driven
-- notifications carry no free text, so add an optional body column that only
-- the 'admin_message' kind populates (all existing kinds leave it NULL).
ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS body TEXT;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS banned_at;
+6
View File
@@ -0,0 +1,6 @@
-- Account suspension (ban) support for the admin panel. Additive: the column
-- is nullable and defaults to NULL (not banned), so existing rows and queries
-- are unaffected. Enforcement: RequireAuth rejects a user whose banned_at is
-- set, and LogIn refuses to issue a new session for one.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS banned_at TIMESTAMPTZ;
+4
View File
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS posts_not_deleted_idx;
DROP INDEX IF EXISTS post_comments_not_deleted_idx;
ALTER TABLE posts DROP COLUMN IF EXISTS deleted_at;
ALTER TABLE post_comments DROP COLUMN IF EXISTS deleted_at;
+18
View File
@@ -0,0 +1,18 @@
-- Soft-delete (admin moderation) for posts and comments. Additive: the column
-- is nullable and defaults to NULL, and every read query now filters
-- `deleted_at IS NULL` — a no-op for existing rows (all NULL), so nothing in
-- the live app changes until an admin actually deletes something. A soft-
-- deleted row disappears from all reads but stays in the table, recoverable
-- via the admin "restore" action.
ALTER TABLE posts
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE post_comments
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- Partial indexes keep the hot "not deleted" reads fast (they match the
-- deleted_at IS NULL predicate now on every posts/comments query).
CREATE INDEX IF NOT EXISTS posts_not_deleted_idx
ON posts (created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS post_comments_not_deleted_idx
ON post_comments (post_id) WHERE deleted_at IS NULL;
@@ -0,0 +1,3 @@
ALTER TABLE post_reports DROP COLUMN IF EXISTS resolution_note;
ALTER TABLE comment_reports DROP COLUMN IF EXISTS resolution_note;
ALTER TABLE user_reports DROP COLUMN IF EXISTS resolution_note;

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