Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a49bf7591e | ||
|
|
aa61f4f0b1 | ||
|
|
bbe491ae13 | ||
|
|
438258ade0 | ||
|
|
93e92c610c | ||
|
|
4946ce1062 | ||
|
|
025c83eec5 | ||
|
|
c67a657ca2 | ||
|
|
2afbbfa0f2 | ||
|
|
8a2bdf945a | ||
|
|
2135ec334c | ||
|
|
05567ad438 | ||
|
|
6ebaa36158 | ||
|
|
1edee92865 | ||
|
|
0b24ab4b45 | ||
|
|
0655b2314d | ||
|
|
187bcd1442 | ||
|
|
cb62adf2de | ||
|
|
d86d876ac1 |
+55
-13
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/config"
|
"github.com/prabhupadasays/spsays-bff/internal/config"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"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"
|
||||||
"github.com/typesense/typesense-go/v2/typesense/api"
|
"github.com/typesense/typesense-go/v2/typesense/api"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
fullReindex = flag.Bool("full", false, "Drop all collections and reindex everything")
|
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)")
|
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
|
// All supported collection names
|
||||||
@@ -146,8 +148,11 @@ func main() {
|
|||||||
for _, name := range collectionsToIndex {
|
for _, name := range collectionsToIndex {
|
||||||
sugar.Infof("=== Processing collection: %s ===", name)
|
sugar.Infof("=== Processing collection: %s ===", name)
|
||||||
|
|
||||||
shouldDrop := *fullReindex || *onlyFlag != ""
|
shouldDrop := (*fullReindex || *onlyFlag != "") && !*noDrop
|
||||||
if !shouldDrop && existingCollections[name] {
|
// 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)
|
sugar.Infof("[%s] Collection already exists, skipping (use --full to force reindex)", name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -187,23 +192,46 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sugar.Info("=== Indexing complete ===")
|
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.
|
// importBatch imports documents into Typesense in batches of batchSize.
|
||||||
func importBatch(sugar *zap.SugaredLogger, tsClient *typesense.Client, collectionName string, docs []interface{}, batchSize int) {
|
func importBatch(sugar *zap.SugaredLogger, tsClient *typesense.Client, collectionName string, docs []interface{}, batchSize int) {
|
||||||
total := len(docs)
|
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 {
|
for i := 0; i < total; i += batchSize {
|
||||||
end := i + batchSize
|
end := i + batchSize
|
||||||
if end > total {
|
if end > total {
|
||||||
end = total
|
end = total
|
||||||
}
|
}
|
||||||
batch := docs[i:end]
|
batch := docs[i:end]
|
||||||
responses, err := tsClient.Collection(collectionName).Documents().Import(context.Background(), batch, &api.ImportDocumentsParams{
|
// Retry the batch on transient transport errors (flaky networks can
|
||||||
Action: &action,
|
// 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 {
|
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
|
continue
|
||||||
}
|
}
|
||||||
// Check for per-document errors
|
// Check for per-document errors
|
||||||
@@ -371,9 +399,12 @@ func indexProse(sugar *zap.SugaredLogger, tsClient *typesense.Client, mongoDB *r
|
|||||||
|
|
||||||
_, err := tsClient.Collections().Create(context.Background(), schema)
|
_, err := tsClient.Collections().Create(context.Background(), schema)
|
||||||
if err != nil {
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -406,11 +437,20 @@ func indexProse(sugar *zap.SugaredLogger, tsClient *typesense.Client, mongoDB *r
|
|||||||
content = joinParagraphs(strs)
|
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
|
var proseURL string
|
||||||
if book == "kb" {
|
switch {
|
||||||
|
case book == "kb":
|
||||||
proseURL = fmt.Sprintf("/books/kb/%d/%s", volume, slug)
|
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)
|
proseURL = fmt.Sprintf("/books/%s/%s", book, slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +468,9 @@ func indexProse(sugar *zap.SugaredLogger, tsClient *typesense.Client, mongoDB *r
|
|||||||
}
|
}
|
||||||
|
|
||||||
sugar.Infof("[prose] Read %d documents from MongoDB", len(docs))
|
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 ====================
|
// ==================== LETTERS ====================
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Command seed-book-intro seeds a book's front-matter (intro-segments such as
|
||||||
|
// Dedication, Front Flap, Preface, Introduction, Disciplic Succession) into the
|
||||||
|
// `prose_chapters` MongoDB collection (volume=0, type="intro-segment").
|
||||||
|
//
|
||||||
|
// The segment JSON is produced by the per-book extractor scripts, e.g.
|
||||||
|
// extractor/scripts/bg_intro_extract.py, cc_intro_extract.py.
|
||||||
|
//
|
||||||
|
// Usage (from the spsays-bff directory, so .env is picked up):
|
||||||
|
//
|
||||||
|
// go run ./cmd/seed-book-intro --book cc
|
||||||
|
// go run ./cmd/seed-book-intro --book bg --dir ../extractor/jsons/books/bg/intro
|
||||||
|
//
|
||||||
|
// It is idempotent: a book's existing intro-segments are removed before insert.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/config"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type introSegment struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Book string `json:"book"`
|
||||||
|
Volume int `json:"volume"`
|
||||||
|
Chapter int `json:"chapter"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
Paragraphs []string `json:"paragraphs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
book := flag.String("book", "", "book code (e.g. bg, cc) — required")
|
||||||
|
segType := flag.String("type", "intro-segment",
|
||||||
|
"prose_chapters type to seed (intro-segment | chapter-summary)")
|
||||||
|
dir := flag.String("dir", "", "directory of segment JSON files "+
|
||||||
|
"(default: ../extractor/jsons/books/<book>/<intro|chapter-summaries>)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *book == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "--book is required")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
segDir := *dir
|
||||||
|
if segDir == "" {
|
||||||
|
sub := "intro"
|
||||||
|
if *segType == "chapter-summary" {
|
||||||
|
sub = "chapter-summaries"
|
||||||
|
}
|
||||||
|
segDir = filepath.Join("..", "extractor", "jsons", "books", *book, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger, _ := zap.NewDevelopment()
|
||||||
|
defer logger.Sync()
|
||||||
|
sugar := logger.Sugar()
|
||||||
|
|
||||||
|
mongoDB, err := repository.NewMongoDB(cfg.MongoURI, cfg.MongoDB, logger)
|
||||||
|
if err != nil {
|
||||||
|
sugar.Fatalf("failed to connect to MongoDB: %v", err)
|
||||||
|
}
|
||||||
|
defer mongoDB.Close()
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(segDir)
|
||||||
|
if err != nil {
|
||||||
|
sugar.Fatalf("failed to read dir %s: %v", segDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && filepath.Ext(e.Name()) == ".json" {
|
||||||
|
files = append(files, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
|
||||||
|
var segments []introSegment
|
||||||
|
for _, name := range files {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(segDir, name))
|
||||||
|
if err != nil {
|
||||||
|
sugar.Fatalf("read %s: %v", name, err)
|
||||||
|
}
|
||||||
|
var seg introSegment
|
||||||
|
if err := json.Unmarshal(raw, &seg); err != nil {
|
||||||
|
sugar.Fatalf("parse %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if seg.Book != *book || seg.Type != *segType {
|
||||||
|
sugar.Warnf("skipping %s (book=%q type=%q)", name, seg.Book, seg.Type)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segments = append(segments, seg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(segments) == 0 {
|
||||||
|
sugar.Fatalf("no %s %s docs found in %s", *book, *segType, segDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
col := mongoDB.Collection("prose_chapters")
|
||||||
|
|
||||||
|
del, err := col.DeleteMany(ctx, map[string]any{
|
||||||
|
"book": *book,
|
||||||
|
"type": *segType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
sugar.Fatalf("delete existing %s %s docs: %v", *book, *segType, err)
|
||||||
|
}
|
||||||
|
sugar.Infof("removed %d existing %s %s doc(s)", del.DeletedCount, *book, *segType)
|
||||||
|
|
||||||
|
docs := make([]any, 0, len(segments))
|
||||||
|
for _, seg := range segments {
|
||||||
|
// chapter is null unless the segment carries one (chapter-summaries do).
|
||||||
|
var chapter any
|
||||||
|
if seg.Chapter > 0 {
|
||||||
|
chapter = seg.Chapter
|
||||||
|
}
|
||||||
|
docs = append(docs, map[string]any{
|
||||||
|
"book": *book,
|
||||||
|
"volume": 0,
|
||||||
|
"chapter": chapter,
|
||||||
|
"title": seg.Title,
|
||||||
|
"type": *segType,
|
||||||
|
"slug": seg.Slug,
|
||||||
|
"order": seg.Order,
|
||||||
|
"paragraphs": seg.Paragraphs,
|
||||||
|
})
|
||||||
|
sugar.Infof(" %s (order %d, %d paragraphs)", seg.Slug, seg.Order, len(seg.Paragraphs))
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := col.InsertMany(ctx, docs)
|
||||||
|
if err != nil {
|
||||||
|
sugar.Fatalf("insert %s %s docs: %v", *book, *segType, err)
|
||||||
|
}
|
||||||
|
sugar.Infof("inserted %d %s %s doc(s)", len(res.InsertedIDs), *book, *segType)
|
||||||
|
}
|
||||||
@@ -66,6 +66,15 @@ type Config struct {
|
|||||||
// Frontend
|
// Frontend
|
||||||
FrontendURL string // for CORS + reset/verify links in emails
|
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
|
// BFF public URL — the externally-reachable origin of this server. Used
|
||||||
// to build the OAuth redirect_uri the provider sees (must match the
|
// to build the OAuth redirect_uri the provider sees (must match the
|
||||||
// allow-list configured in the provider's console). In dev this is
|
// allow-list configured in the provider's console). In dev this is
|
||||||
@@ -147,7 +156,9 @@ func Load() (*Config, error) {
|
|||||||
CookieDomain: getEnv("COOKIE_DOMAIN", ""),
|
CookieDomain: getEnv("COOKIE_DOMAIN", ""),
|
||||||
CookieSecure: getEnvBool("COOKIE_SECURE", false),
|
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"),
|
BFFPublicURL: getEnv("BFF_PUBLIC_URL", "http://localhost:8080"),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminHandler serves /api/v1/admin/* — gated by RequireAuth + RequireAdmin in
|
||||||
|
// the router, so every method here can assume an admin caller.
|
||||||
|
type AdminHandler struct{ svc *service.AdminService }
|
||||||
|
|
||||||
|
func NewAdminHandler(svc *service.AdminService) *AdminHandler { return &AdminHandler{svc: svc} }
|
||||||
|
|
||||||
|
// pageParams pulls (q, limit, offset) from the query string with sane defaults.
|
||||||
|
func pageParams(r *http.Request) (q string, limit, offset int) {
|
||||||
|
q = strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
limit = 25
|
||||||
|
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 {
|
||||||
|
limit = v
|
||||||
|
}
|
||||||
|
offset = 0
|
||||||
|
if v, err := strconv.Atoi(r.URL.Query().Get("offset")); err == nil && v >= 0 {
|
||||||
|
offset = v
|
||||||
|
}
|
||||||
|
return q, limit, offset
|
||||||
|
}
|
||||||
|
|
||||||
|
// meta builds the pagination envelope metadata from a total + the request page.
|
||||||
|
func meta(total, limit, offset int) *response.Meta {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 25
|
||||||
|
}
|
||||||
|
pages := (total + limit - 1) / limit
|
||||||
|
return &response.Meta{
|
||||||
|
Page: offset/limit + 1,
|
||||||
|
Limit: limit,
|
||||||
|
Total: int64(total),
|
||||||
|
TotalPages: pages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q, limit, offset := pageParams(r)
|
||||||
|
items, total, err := h.svc.ListUsers(r.Context(), q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not list users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListPosts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q, limit, offset := pageParams(r)
|
||||||
|
items, total, err := h.svc.ListPosts(r.Context(), q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not list posts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListCollections(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q, limit, offset := pageParams(r)
|
||||||
|
items, total, err := h.svc.ListCollections(r.Context(), q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not list collections")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListFeedback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, limit, offset := pageParams(r)
|
||||||
|
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||||
|
items, total, err := h.svc.ListFeedback(r.Context(), status, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not list feedback")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) ListReports(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q, limit, offset := pageParams(r)
|
||||||
|
reportType := strings.TrimSpace(r.URL.Query().Get("type"))
|
||||||
|
if reportType == "" {
|
||||||
|
reportType = "post"
|
||||||
|
}
|
||||||
|
if reportType != "post" && reportType != "comment" && reportType != "user" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "type must be post, comment, or user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, total, err := h.svc.ListReports(r.Context(), reportType, q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not list reports")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSONWithMeta(w, http.StatusOK, items, meta(total, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyUser — POST /admin/users/{id}/notify { "title": "...", "body": "..." }
|
||||||
|
func (h *AdminHandler) NotifyUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var in struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Title = strings.TrimSpace(in.Title)
|
||||||
|
in.Body = strings.TrimSpace(in.Body)
|
||||||
|
if in.Body == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "message body is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(in.Title) > 120 || len(in.Body) > 4000 {
|
||||||
|
response.Error(w, http.StatusBadRequest, "message too long")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.svc.NotifyUser(r.Context(), id, in.Title, in.Body)
|
||||||
|
if errors.Is(err, service.ErrAdminUserNotFound) {
|
||||||
|
response.Error(w, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not send notification")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"sent": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplyFeedback — POST /admin/feedback/{id}/reply { "message": "..." }
|
||||||
|
func (h *AdminHandler) ReplyFeedback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var in struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Message = strings.TrimSpace(in.Message)
|
||||||
|
if in.Message == "" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "reply message is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(in.Message) > 4000 {
|
||||||
|
response.Error(w, http.StatusBadRequest, "reply too long")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found, emailed, err := h.svc.ReplyFeedback(r.Context(), id, in.Message)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not save reply")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
response.Error(w, http.StatusNotFound, "feedback not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"replied": true, "emailed": emailed})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveReport — POST /admin/reports/{type}/{id}/resolve
|
||||||
|
func (h *AdminHandler) ResolveReport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reportType := chi.URLParam(r, "type")
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if reportType != "post" && reportType != "comment" && reportType != "user" {
|
||||||
|
response.Error(w, http.StatusBadRequest, "type must be post, comment, or user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolved, err := h.svc.ResolveReport(r.Context(), reportType, id)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not resolve report")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// resolved=false means it was already resolved or unknown — still 200 so
|
||||||
|
// the action is idempotent from the client's perspective.
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"resolved": resolved})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Moderation actions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// HidePost — POST /admin/posts/{id}/hide
|
||||||
|
func (h *AdminHandler) HidePost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.setPostHidden(w, r, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnhidePost — DELETE /admin/posts/{id}/hide
|
||||||
|
func (h *AdminHandler) UnhidePost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.setPostHidden(w, r, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) setPostHidden(w http.ResponseWriter, r *http.Request, hidden bool) {
|
||||||
|
ok, err := h.svc.SetPostHidden(r.Context(), chi.URLParam(r, "id"), hidden)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not update post")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
response.Error(w, http.StatusNotFound, "post not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"hidden": hidden})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePost — DELETE /admin/posts/{id} (soft-delete; restorable)
|
||||||
|
func (h *AdminHandler) DeletePost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ok, err := h.svc.DeletePost(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not delete post")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
response.Error(w, http.StatusNotFound, "post not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestorePost — POST /admin/posts/{id}/restore
|
||||||
|
func (h *AdminHandler) RestorePost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ok, err := h.svc.RestorePost(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not restore post")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
response.Error(w, http.StatusNotFound, "post not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"restored": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteComment — DELETE /admin/comments/{id} (soft-delete; restorable)
|
||||||
|
func (h *AdminHandler) DeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ok, err := h.svc.DeleteComment(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not delete comment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
response.Error(w, http.StatusNotFound, "comment not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreComment — POST /admin/comments/{id}/restore
|
||||||
|
func (h *AdminHandler) RestoreComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ok, err := h.svc.RestoreComment(r.Context(), chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not restore comment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
response.Error(w, http.StatusNotFound, "comment not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"restored": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BanUser — POST /admin/users/{id}/ban
|
||||||
|
func (h *AdminHandler) BanUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.setUserBanned(w, r, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnbanUser — DELETE /admin/users/{id}/ban
|
||||||
|
func (h *AdminHandler) UnbanUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.setUserBanned(w, r, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) setUserBanned(w http.ResponseWriter, r *http.Request, banned bool) {
|
||||||
|
ok, err := h.svc.SetUserBanned(r.Context(), chi.URLParam(r, "id"), banned)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "could not update account")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
response.Error(w, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, map[string]any{"banned": banned})
|
||||||
|
}
|
||||||
@@ -375,6 +375,8 @@ func (h *AuthHandler) renderAuthErr(w http.ResponseWriter, err error) {
|
|||||||
response.Error(w, http.StatusBadRequest, err.Error())
|
response.Error(w, http.StatusBadRequest, err.Error())
|
||||||
case errors.Is(err, service.ErrUserNotFound):
|
case errors.Is(err, service.ErrUserNotFound):
|
||||||
response.Error(w, http.StatusNotFound, err.Error())
|
response.Error(w, http.StatusNotFound, err.Error())
|
||||||
|
case errors.Is(err, service.ErrAccountSuspended):
|
||||||
|
response.Error(w, http.StatusForbidden, err.Error())
|
||||||
default:
|
default:
|
||||||
response.Error(w, http.StatusInternalServerError, "something went wrong")
|
response.Error(w, http.StatusInternalServerError, "something went wrong")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/service"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommentHandler serves the post-comment endpoints: listing top-level comments
|
||||||
|
// + a thread's replies (public), and create / like / pin / delete / report
|
||||||
|
// (auth-required).
|
||||||
|
type CommentHandler struct {
|
||||||
|
svc *service.CommentService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCommentHandler(svc *service.CommentService) *CommentHandler {
|
||||||
|
return &CommentHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createCommentReq struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
// ParentID nil → top-level comment; otherwise the comment being replied
|
||||||
|
// to (the service flattens replies onto the thread root).
|
||||||
|
ParentID *string `json:"parent_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type commentLikeResp struct {
|
||||||
|
LikesCount int `json:"likes_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List — GET /posts/{id}/comments?limit=&offset=. Top-level comments, pinned
|
||||||
|
// first then newest. Public (OptionalAuth populates viewer_has_liked).
|
||||||
|
func (h *CommentHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
postID := chi.URLParam(r, "id")
|
||||||
|
viewerID := middleware.UserID(r.Context())
|
||||||
|
limit, offset := parseLimitOffset(r)
|
||||||
|
items, total, err := h.svc.List(r.Context(), postID, viewerID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSONWithMeta(w, http.StatusOK, items, paginationMeta(total, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replies — GET /comments/{id}/replies?limit=&offset=. A thread's replies,
|
||||||
|
// oldest first. Public (OptionalAuth).
|
||||||
|
func (h *CommentHandler) Replies(w http.ResponseWriter, r *http.Request) {
|
||||||
|
commentID := chi.URLParam(r, "id")
|
||||||
|
viewerID := middleware.UserID(r.Context())
|
||||||
|
limit, offset := parseLimitOffset(r)
|
||||||
|
items, total, err := h.svc.Replies(r.Context(), commentID, viewerID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSONWithMeta(w, http.StatusOK, items, paginationMeta(total, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create — POST /posts/{id}/comments. Body: {body, parent_id?}. RequireAuth.
|
||||||
|
func (h *CommentHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
postID := chi.URLParam(r, "id")
|
||||||
|
var req createCommentReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Treat an empty parent_id as a top-level comment, not a (missing) reply.
|
||||||
|
if req.ParentID != nil && *req.ParentID == "" {
|
||||||
|
req.ParentID = nil
|
||||||
|
}
|
||||||
|
c, err := h.svc.Add(r.Context(), uid, postID, req.ParentID, req.Body)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusCreated, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like — POST /comments/{id}/like. Idempotent. RequireAuth.
|
||||||
|
func (h *CommentHandler) Like(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
count, err := h.svc.Like(r.Context(), uid, id)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, commentLikeResp{LikesCount: count})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlike — DELETE /comments/{id}/like. Idempotent. RequireAuth.
|
||||||
|
func (h *CommentHandler) Unlike(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
count, err := h.svc.Unlike(r.Context(), uid, id)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSON(w, http.StatusOK, commentLikeResp{LikesCount: count})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin — POST /comments/{id}/pin. Post author only; top-level only. RequireAuth.
|
||||||
|
func (h *CommentHandler) Pin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := h.svc.Pin(r.Context(), uid, id); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpin — DELETE /comments/{id}/pin. Post author only. RequireAuth.
|
||||||
|
func (h *CommentHandler) Unpin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := h.svc.Unpin(r.Context(), uid, id); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete — DELETE /comments/{id}. Comment author or post author. RequireAuth.
|
||||||
|
// For a thread root this removes the whole thread.
|
||||||
|
func (h *CommentHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if err := h.svc.Delete(r.Context(), uid, id); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report — POST /comments/{id}/report. Body: {category, reason?}. RequireAuth.
|
||||||
|
func (h *CommentHandler) Report(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var req reportPostReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Report(r.Context(), uid, id, req.Category, req.Reason); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(w)
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
||||||
@@ -49,3 +51,78 @@ func (h *NotificationHandler) MarkAllRead(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
response.NoContent(w)
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -19,6 +20,11 @@ const (
|
|||||||
maxEndpointLen = 2048
|
maxEndpointLen = 2048
|
||||||
maxKeyLen = 256
|
maxKeyLen = 256
|
||||||
maxDevicesPerUser = 10
|
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
|
// validatePushEndpoint requires an https URL belonging to a known browser push
|
||||||
@@ -46,6 +52,21 @@ func validatePushEndpoint(endpoint string) bool {
|
|||||||
return false
|
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
|
// PushHandler serves Web Push subscription management. The VAPID public key
|
||||||
// is handed to the browser so it can subscribe; subscriptions are stored per
|
// is handed to the browser so it can subscribe; subscriptions are stored per
|
||||||
// user. All routes require auth.
|
// 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")
|
response.Error(w, http.StatusBadRequest, "invalid push endpoint")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Keys.P256dh) > maxKeyLen || len(req.Keys.Auth) > maxKeyLen {
|
if !validateSubscriptionKey(req.Keys.P256dh, p256dhLen) ||
|
||||||
response.Error(w, http.StatusBadRequest, "subscription keys too long")
|
!validateSubscriptionKey(req.Keys.Auth, authLen) {
|
||||||
|
response.Error(w, http.StatusBadRequest, "invalid subscription keys")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.repo.Upsert(r.Context(), uid, req.Endpoint, req.Keys.P256dh, req.Keys.Auth, r.UserAgent()); err != nil {
|
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"`
|
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) {
|
func (h *PushHandler) Unsubscribe(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.repo == nil {
|
if h.repo == nil {
|
||||||
response.NoContent(w)
|
response.NoContent(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
uid := middleware.UserID(r.Context())
|
||||||
var req unsubscribeReq
|
var req unsubscribeReq
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Endpoint) == "" {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Endpoint) == "" {
|
||||||
response.Error(w, http.StatusBadRequest, "missing endpoint")
|
response.Error(w, http.StatusBadRequest, "missing endpoint")
|
||||||
return
|
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")
|
response.Error(w, http.StatusInternalServerError, "could not remove subscription")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
+241
-26
@@ -59,10 +59,11 @@ func (h *PostHandler) Report(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type createPostReq struct {
|
type createPostReq struct {
|
||||||
Caption string `json:"caption"`
|
Caption string `json:"caption"`
|
||||||
SourceURL string `json:"source_url"`
|
SourceURL string `json:"source_url"`
|
||||||
SourceTitle string `json:"source_title"`
|
SourceTitle string `json:"source_title"`
|
||||||
ImageURLs []string `json:"image_urls"`
|
ImageURLs []string `json:"image_urls"`
|
||||||
|
MentionUserIDs []string `json:"mention_user_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -79,7 +80,7 @@ func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusBadRequest, v.Error())
|
response.Error(w, http.StatusBadRequest, v.Error())
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
mapSocialErr(w, err)
|
mapSocialErr(w, err)
|
||||||
return
|
return
|
||||||
@@ -87,6 +88,60 @@ func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.JSON(w, http.StatusCreated, p)
|
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
|
// Reshare materialises a reshare of the post identified by {id} as a
|
||||||
// new posts row owned by the caller. Idempotent — second call returns
|
// new posts row owned by the caller. Idempotent — second call returns
|
||||||
// the same row (still 201). The body is the new (or existing) post so
|
// the same row (still 201). The body is the new (or existing) post so
|
||||||
@@ -179,6 +234,27 @@ func (h *PostHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.NoContent(w)
|
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) {
|
func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) {
|
||||||
uid := middleware.UserID(r.Context())
|
uid := middleware.UserID(r.Context())
|
||||||
limit, offset := parseLimitOffset(r)
|
limit, offset := parseLimitOffset(r)
|
||||||
@@ -190,6 +266,57 @@ func (h *PostHandler) ListMine(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
|
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 {
|
type replaceImagesReq struct {
|
||||||
ImageURLs []string `json:"image_urls"`
|
ImageURLs []string `json:"image_urls"`
|
||||||
}
|
}
|
||||||
@@ -489,10 +616,12 @@ type ProfileHandler struct {
|
|||||||
collections *repository.CollectionRepo
|
collections *repository.CollectionRepo
|
||||||
graph *service.SocialGraphService
|
graph *service.SocialGraphService
|
||||||
likes *repository.LikeRepo
|
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 {
|
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}
|
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) {
|
func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -507,6 +636,16 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.Error(w, http.StatusInternalServerError, "could not load profile")
|
response.Error(w, http.StatusInternalServerError, "could not load profile")
|
||||||
return
|
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)
|
cols, err := h.collections.ListPublicByUser(r.Context(), u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(w, http.StatusInternalServerError, "could not load collections")
|
response.Error(w, http.StatusInternalServerError, "could not load collections")
|
||||||
@@ -530,6 +669,7 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
"joined_at": u.CreatedAt,
|
"joined_at": u.CreatedAt,
|
||||||
"country": u.Country,
|
"country": u.Country,
|
||||||
"post_count": count,
|
"post_count": count,
|
||||||
|
"hidden_count": hiddenCount,
|
||||||
"follower_count": followers,
|
"follower_count": followers,
|
||||||
"following_count": following,
|
"following_count": following,
|
||||||
"relationship": rel,
|
"relationship": rel,
|
||||||
@@ -537,6 +677,32 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Feed returns posts for the public feed. Same payload as ListMine but
|
||||||
// looked up by handle so it's reachable anonymously. When an
|
// looked up by handle so it's reachable anonymously. When an
|
||||||
// authenticated viewer hits this route (via OptionalAuth) each post
|
// authenticated viewer hits this route (via OptionalAuth) each post
|
||||||
@@ -544,27 +710,66 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
// button. Anonymous viewers get `false` for every post at zero cost.
|
// button. Anonymous viewers get `false` for every post at zero cost.
|
||||||
func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
|
func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
|
||||||
handle := chi.URLParam(r, "handle")
|
handle := chi.URLParam(r, "handle")
|
||||||
limit, offset := parseLimitOffset(r)
|
owner, err := h.gateByBlock(r, handle)
|
||||||
posts, total, err := h.posts.ListByHandle(r.Context(), handle, limit, offset)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mapSocialErr(w, err)
|
mapSocialErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
limit, offset := parseLimitOffset(r)
|
||||||
|
// gateByBlock already resolved the owner, so list by id rather than
|
||||||
|
// re-resolving the handle. Hidden posts never appear here (they live in
|
||||||
|
// the owner's separate Hidden tab).
|
||||||
|
posts, total, err := h.posts.ListByUser(r.Context(), owner.ID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.hydrateFeed(r, posts); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MentionsFeed — GET /u/{handle}/mentions?limit=&offset=. Posts that tag this
|
||||||
|
// user, newest first. Public (OptionalAuth) with viewer-state hydration.
|
||||||
|
func (h *ProfileHandler) MentionsFeed(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handle := chi.URLParam(r, "handle")
|
||||||
|
if _, err := h.gateByBlock(r, handle); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, offset := parseLimitOffset(r)
|
||||||
|
posts, total, err := h.posts.ListMentioningByHandle(r.Context(), handle, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.hydrateFeed(r, posts); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.JSONWithMeta(w, http.StatusOK, posts, paginationMeta(total, limit, offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hydrateFeed fills viewer reshare state + like counts/state + mention counts
|
||||||
|
// across a page of posts. Shared by the profile feed and the mentions feed.
|
||||||
|
func (h *ProfileHandler) hydrateFeed(r *http.Request, posts []model.Post) error {
|
||||||
|
if len(posts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
viewerID := middleware.UserID(r.Context())
|
viewerID := middleware.UserID(r.Context())
|
||||||
if viewerID != "" && len(posts) > 0 {
|
if viewerID != "" {
|
||||||
ids := make([]string, 0, len(posts))
|
ids := make([]string, 0, len(posts))
|
||||||
for i := range posts {
|
for i := range posts {
|
||||||
// Skip own posts — can't reshare yourself, and the swap
|
if posts[i].UserID != viewerID { // can't reshare your own post
|
||||||
// already triggers on isOwner.
|
|
||||||
if posts[i].UserID != viewerID {
|
|
||||||
ids = append(ids, posts[i].ID)
|
ids = append(ids, posts[i].ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(ids) > 0 {
|
if len(ids) > 0 {
|
||||||
states, err := h.posts.ReshareStatesFor(r.Context(), viewerID, ids)
|
states, err := h.posts.ReshareStatesFor(r.Context(), viewerID, ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
mapSocialErr(w, err)
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
for i := range posts {
|
for i := range posts {
|
||||||
if states[posts[i].ID] {
|
if states[posts[i].ID] {
|
||||||
@@ -573,18 +778,17 @@ func (h *ProfileHandler) Feed(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Like counts (effective) + viewer-liked state for every post in the page.
|
ptrs := make([]*model.Post, len(posts))
|
||||||
if len(posts) > 0 {
|
for i := range posts {
|
||||||
ptrs := make([]*model.Post, len(posts))
|
ptrs[i] = &posts[i]
|
||||||
for i := range posts {
|
|
||||||
ptrs[i] = &posts[i]
|
|
||||||
}
|
|
||||||
if err := h.likes.Hydrate(r.Context(), viewerID, ptrs); err != nil {
|
|
||||||
mapSocialErr(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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:
|
// followListResp is the payload for the followers / following list endpoints:
|
||||||
@@ -600,6 +804,10 @@ type followListResp struct {
|
|||||||
// name/handle search. Public (OptionalAuth).
|
// name/handle search. Public (OptionalAuth).
|
||||||
func (h *ProfileHandler) Followers(w http.ResponseWriter, r *http.Request) {
|
func (h *ProfileHandler) Followers(w http.ResponseWriter, r *http.Request) {
|
||||||
handle := chi.URLParam(r, "handle")
|
handle := chi.URLParam(r, "handle")
|
||||||
|
if _, err := h.gateByBlock(r, handle); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
limit, offset := parseLimitOffset(r)
|
limit, offset := parseLimitOffset(r)
|
||||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
users, total, err := h.graph.Followers(r.Context(), handle, q, limit, offset)
|
users, total, err := h.graph.Followers(r.Context(), handle, q, limit, offset)
|
||||||
@@ -613,6 +821,10 @@ func (h *ProfileHandler) Followers(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Following — GET /u/{handle}/following?limit=&offset=&q=.
|
// Following — GET /u/{handle}/following?limit=&offset=&q=.
|
||||||
func (h *ProfileHandler) Following(w http.ResponseWriter, r *http.Request) {
|
func (h *ProfileHandler) Following(w http.ResponseWriter, r *http.Request) {
|
||||||
handle := chi.URLParam(r, "handle")
|
handle := chi.URLParam(r, "handle")
|
||||||
|
if _, err := h.gateByBlock(r, handle); err != nil {
|
||||||
|
mapSocialErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
limit, offset := parseLimitOffset(r)
|
limit, offset := parseLimitOffset(r)
|
||||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
users, total, err := h.graph.Following(r.Context(), handle, q, limit, offset)
|
users, total, err := h.graph.Following(r.Context(), handle, q, limit, offset)
|
||||||
@@ -631,10 +843,13 @@ func mapSocialErr(w http.ResponseWriter, err error) {
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, repository.ErrUserNotFound),
|
case errors.Is(err, repository.ErrUserNotFound),
|
||||||
errors.Is(err, repository.ErrPostNotFound),
|
errors.Is(err, repository.ErrPostNotFound),
|
||||||
|
errors.Is(err, repository.ErrCommentNotFound),
|
||||||
errors.Is(err, repository.ErrBookmarkNotFound),
|
errors.Is(err, repository.ErrBookmarkNotFound),
|
||||||
errors.Is(err, repository.ErrCollectionNotFound),
|
errors.Is(err, repository.ErrCollectionNotFound),
|
||||||
errors.Is(err, service.ErrNotFound):
|
errors.Is(err, service.ErrNotFound):
|
||||||
response.Error(w, http.StatusNotFound, "not found")
|
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):
|
case errors.Is(err, service.ErrForbidden):
|
||||||
response.Error(w, http.StatusForbidden, "not allowed")
|
response.Error(w, http.StatusForbidden, "not allowed")
|
||||||
case errors.Is(err, service.ErrBlocked):
|
case errors.Is(err, service.ErrBlocked):
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notifChannelPrefix is the public per-user channel the frontend subscribes to
|
||||||
|
// for realtime notifications. Because it's keyed by user, a `channel_vacated`
|
||||||
|
// webhook on it means that user's *last* connection dropped — i.e. they went
|
||||||
|
// offline (across all their tabs) — so we can evict their feed seen-set.
|
||||||
|
const notifChannelPrefix = "notifications."
|
||||||
|
|
||||||
|
// SoketiWebhookHandler receives Soketi (Pusher-compatible) webhooks. Soketi
|
||||||
|
// signs each delivery with the app secret (X-Pusher-Signature = hex
|
||||||
|
// HMAC-SHA256 of the raw body); we verify before acting. On channel_vacated
|
||||||
|
// for a per-user notifications channel we clear that user's home-feed seen-set
|
||||||
|
// — prompt cleanup of a true offline/disconnect, complementing the page-unload
|
||||||
|
// beacon and the set's own TTL.
|
||||||
|
type SoketiWebhookHandler struct {
|
||||||
|
appKey string
|
||||||
|
appSecret string
|
||||||
|
feed *repository.FeedRepo
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSoketiWebhookHandler(appKey, appSecret string, feed *repository.FeedRepo, logger *zap.Logger) *SoketiWebhookHandler {
|
||||||
|
return &SoketiWebhookHandler{appKey: appKey, appSecret: appSecret, feed: feed, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
type soketiWebhookBody struct {
|
||||||
|
Events []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
} `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle — POST /webhooks/soketi. Public route: the HMAC signature is the
|
||||||
|
// authentication, not a session cookie. Returns 200 on a valid signature so
|
||||||
|
// Soketi doesn't retry; 401 on a bad/absent signature.
|
||||||
|
func (h *SoketiWebhookHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Without a configured secret there's nothing to verify against — refuse
|
||||||
|
// rather than trust unsigned input.
|
||||||
|
if h.appSecret == "" {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MiB cap
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if key := r.Header.Get("X-Pusher-Key"); h.appKey != "" && key != h.appKey {
|
||||||
|
h.logger.Warn("soketi webhook: app key mismatch", zap.String("got", key))
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validSoketiSignature(h.appSecret, r.Header.Get("X-Pusher-Signature"), body) {
|
||||||
|
// Almost always a secret mismatch between Soketi and the BFF.
|
||||||
|
h.logger.Warn("soketi webhook: bad signature", zap.Int("body_bytes", len(body)))
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload soketiWebhookBody
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
h.logger.Warn("soketi webhook: bad payload", zap.Error(err))
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
cleared := 0
|
||||||
|
for _, e := range payload.Events {
|
||||||
|
// A per-user notifications channel emptying = that user is offline.
|
||||||
|
if e.Name != "channel_vacated" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if uid, ok := strings.CutPrefix(e.Channel, notifChannelPrefix); ok && uid != "" {
|
||||||
|
h.feed.Reset(ctx, uid)
|
||||||
|
cleared++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.logger.Debug("soketi webhook: verified",
|
||||||
|
zap.Int("events", len(payload.Events)),
|
||||||
|
zap.Int("feed_sets_cleared", cleared))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validSoketiSignature is a constant-time check of the hex HMAC-SHA256 of the
|
||||||
|
// raw body keyed by the app secret.
|
||||||
|
func validSoketiSignature(secret, provided string, body []byte) bool {
|
||||||
|
if provided == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write(body)
|
||||||
|
expected := hex.EncodeToString(mac.Sum(nil))
|
||||||
|
return hmac.Equal([]byte(expected), []byte(provided))
|
||||||
|
}
|
||||||
+25
-10
@@ -94,21 +94,31 @@ type userSearchItem struct {
|
|||||||
// the query carries an "@" prefix.
|
// the query carries an "@" prefix.
|
||||||
func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) {
|
func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
if q == "" {
|
|
||||||
response.JSON(w, http.StatusOK, map[string]any{
|
|
||||||
"query": "",
|
|
||||||
"results": []userSearchItem{},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
limit := 20
|
limit := 20
|
||||||
if v := r.URL.Query().Get("limit"); v != "" {
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 {
|
||||||
limit = n
|
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 {
|
if err != nil {
|
||||||
response.Error(w, http.StatusInternalServerError, "search failed")
|
response.Error(w, http.StatusInternalServerError, "search failed")
|
||||||
return
|
return
|
||||||
@@ -122,9 +132,14 @@ func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
AvatarURL: u.AvatarURL,
|
AvatarURL: u.AvatarURL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
totalPages := (total + limit - 1) / limit
|
||||||
response.JSON(w, http.StatusOK, map[string]any{
|
response.JSON(w, http.StatusOK, map[string]any{
|
||||||
"query": q,
|
"query": q,
|
||||||
"results": items,
|
"results": items,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"total_pages": totalPages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequireAdmin gates a route to users whose role is "admin". It MUST run after
|
||||||
|
// RequireAuth (it reads the authenticated user id from context, then looks up
|
||||||
|
// the row to check the role). Non-admins get 403; this keeps the whole
|
||||||
|
// /api/v1/admin/* surface invisible to ordinary accounts.
|
||||||
|
func RequireAdmin(users *repository.UserRepo) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := UserID(r.Context())
|
||||||
|
if uid == "" {
|
||||||
|
response.Error(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := users.FindByID(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(w, http.StatusInternalServerError, "auth check failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u.Role != "admin" {
|
||||||
|
response.Error(w, http.StatusForbidden, "admin access required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,12 @@ func RequireAuth(issuer *auth.Issuer, users *repository.UserRepo) func(http.Hand
|
|||||||
response.Error(w, http.StatusUnauthorized, "session expired — please log in again")
|
response.Error(w, http.StatusUnauthorized, "session expired — please log in again")
|
||||||
return
|
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(r.Context(), userIDKey, claims.UserID)
|
||||||
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
|
ctx = context.WithValue(ctx, tokenVersionKey, claims.TokenVersion)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Admin DTOs — read-only projections served only to admins via /api/v1/admin/*.
|
||||||
|
// They deliberately differ from the public-facing models: they may expose
|
||||||
|
// fields (email, role, reporter identity) that the normal API hides.
|
||||||
|
|
||||||
|
// AdminUser is a row in the admin users table.
|
||||||
|
type AdminUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarURL *string `json:"avatar_url"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Plan string `json:"plan"`
|
||||||
|
EmailVerifiedAt *time.Time `json:"email_verified_at"`
|
||||||
|
BannedAt *time.Time `json:"banned_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPost is a row in the admin posts table.
|
||||||
|
type AdminPost struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Caption string `json:"caption"`
|
||||||
|
SourceTitle string `json:"source_title"`
|
||||||
|
SourceURL string `json:"source_url"`
|
||||||
|
IsHidden bool `json:"is_hidden"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
AuthorName string `json:"author_name"`
|
||||||
|
AuthorHandle string `json:"author_handle"`
|
||||||
|
LikesCount int `json:"likes_count"`
|
||||||
|
CommentsCount int `json:"comments_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCollection is a row in the admin collections table.
|
||||||
|
type AdminCollection struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
IsPublic bool `json:"is_public"`
|
||||||
|
ItemCount int `json:"item_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
OwnerName string `json:"owner_name"`
|
||||||
|
OwnerHandle string `json:"owner_handle"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminFeedback is a row in the admin feedback table (includes the reply).
|
||||||
|
type AdminFeedback struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
ContactEmail *string `json:"contact_email"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at"`
|
||||||
|
RepliedAt *time.Time `json:"replied_at"`
|
||||||
|
ReplyMessage *string `json:"reply_message"`
|
||||||
|
AuthorHandle *string `json:"author_handle"`
|
||||||
|
AuthorName *string `json:"author_name"`
|
||||||
|
AuthorEmail *string `json:"author_email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminReport is a unified projection over the three report tables
|
||||||
|
// (post_reports, comment_reports, user_reports). Type says which one.
|
||||||
|
type AdminReport struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"` // "post" | "comment" | "user"
|
||||||
|
Category string `json:"category"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ResolvedAt *time.Time `json:"resolved_at"`
|
||||||
|
|
||||||
|
ReporterHandle *string `json:"reporter_handle"`
|
||||||
|
ReporterName *string `json:"reporter_name"`
|
||||||
|
|
||||||
|
// Target of the report. TargetID is the post/comment/user id; TargetText
|
||||||
|
// is the post caption, comment body, or reported user's handle so the
|
||||||
|
// admin sees what was flagged without a second fetch. TargetAuthorHandle
|
||||||
|
// is the handle of the content's author (nil for user reports).
|
||||||
|
TargetID string `json:"target_id"`
|
||||||
|
TargetText *string `json:"target_text"`
|
||||||
|
TargetAuthorHandle *string `json:"target_author_handle"`
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ type Post struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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.
|
// Author info — populated by repo joins for feed listings.
|
||||||
AuthorName string `json:"author_name,omitempty"`
|
AuthorName string `json:"author_name,omitempty"`
|
||||||
AuthorHandle string `json:"author_handle,omitempty"`
|
AuthorHandle string `json:"author_handle,omitempty"`
|
||||||
@@ -46,6 +51,57 @@ type Post struct {
|
|||||||
// false for anonymous viewers.
|
// false for anonymous viewers.
|
||||||
LikesCount int `json:"likes_count"`
|
LikesCount int `json:"likes_count"`
|
||||||
ViewerHasLiked bool `json:"viewer_has_liked"`
|
ViewerHasLiked bool `json:"viewer_has_liked"`
|
||||||
|
|
||||||
|
// MentionsCount is how many users are tagged on this post (drives the
|
||||||
|
// post-card mentions icon). Hydrated by the mention layer.
|
||||||
|
MentionsCount int `json:"mentions_count"`
|
||||||
|
|
||||||
|
// PinnedComment is the post's pinned comment (if any), hydrated by the
|
||||||
|
// comment layer so the card footer can show it without a per-card fetch.
|
||||||
|
// Nil when nothing is pinned.
|
||||||
|
PinnedComment *Comment `json:"pinned_comment,omitempty"`
|
||||||
|
|
||||||
|
// CommentsCount is the total number of comments + replies on the post
|
||||||
|
// (drives the footer's comment count). Hydrated by the comment layer.
|
||||||
|
CommentsCount int `json:"comments_count"`
|
||||||
|
|
||||||
|
// Viewer↔author relationship, hydrated for the home feed so the card can
|
||||||
|
// render a Follow / Follow Back affordance. ViewerFollowsAuthor: the
|
||||||
|
// caller follows (accepted) this post's author. AuthorFollowsViewer: the
|
||||||
|
// author follows the caller. Both false for anonymous viewers / own posts.
|
||||||
|
ViewerFollowsAuthor bool `json:"viewer_follows_author"`
|
||||||
|
AuthorFollowsViewer bool `json:"author_follows_viewer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment is one comment (or reply) on a post. Threading is one level deep:
|
||||||
|
// ParentID is nil for a top-level comment and otherwise points at the thread
|
||||||
|
// root. ReplyToHandle, when set, is the @handle the reply was addressed to
|
||||||
|
// (replies stay flat under the root; this just preserves who was answered).
|
||||||
|
type Comment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
PostID string `json:"post_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
LikesCount int `json:"likes_count"`
|
||||||
|
PinnedAt *time.Time `json:"pinned_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// Author info — populated by repo joins.
|
||||||
|
AuthorName string `json:"author_name"`
|
||||||
|
AuthorHandle string `json:"author_handle"`
|
||||||
|
AuthorAvatarURL *string `json:"author_avatar_url,omitempty"`
|
||||||
|
|
||||||
|
// ReplyToHandle is the handle this reply was addressed to (nil on
|
||||||
|
// top-level comments), for the "Replying to @handle" prefix.
|
||||||
|
ReplyToHandle *string `json:"reply_to_handle,omitempty"`
|
||||||
|
|
||||||
|
// ViewerHasLiked reflects the calling user's like membership; false for
|
||||||
|
// anonymous viewers. ReplyCount is the number of replies under a
|
||||||
|
// top-level comment (0 on replies themselves).
|
||||||
|
ViewerHasLiked bool `json:"viewer_has_liked"`
|
||||||
|
ReplyCount int `json:"reply_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostImage represents one attached image on a post.
|
// PostImage represents one attached image on a post.
|
||||||
@@ -184,6 +240,15 @@ type Notification struct {
|
|||||||
ActorName *string `json:"actor_name,omitempty"`
|
ActorName *string `json:"actor_name,omitempty"`
|
||||||
ActorHandle *string `json:"actor_handle,omitempty"`
|
ActorHandle *string `json:"actor_handle,omitempty"`
|
||||||
ActorAvatar *string `json:"actor_avatar_url,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.
|
// PushSubscription is one browser/device Web Push endpoint a user granted.
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import (
|
|||||||
// UserSummary is the lightweight projection used in follower / following
|
// UserSummary is the lightweight projection used in follower / following
|
||||||
// lists — just enough to render a row and link to the profile.
|
// lists — just enough to render a row and link to the profile.
|
||||||
type UserSummary struct {
|
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"`
|
Handle string `json:"handle"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AvatarURL *string `json:"avatar_url"`
|
AvatarURL *string `json:"avatar_url"`
|
||||||
@@ -68,6 +71,10 @@ type User struct {
|
|||||||
TokenVersion int `json:"-"`
|
TokenVersion int `json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// BannedAt suspends the account when set: RequireAuth rejects the session
|
||||||
|
// and LogIn refuses to issue a new one. nil = in good standing.
|
||||||
|
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
|
// MarshalJSON adds a derived `has_password` flag so the frontend can pick
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminRepo holds the read-mostly queries that back the admin panel. Every
|
||||||
|
// list method returns its page plus the unfiltered total (via COUNT(*) OVER())
|
||||||
|
// so the UI can paginate without a second round-trip. All queries are new —
|
||||||
|
// they read existing tables but don't alter any existing repo behaviour.
|
||||||
|
type AdminRepo struct{ pg *Postgres }
|
||||||
|
|
||||||
|
func NewAdminRepo(pg *Postgres) *AdminRepo { return &AdminRepo{pg: pg} }
|
||||||
|
|
||||||
|
// clampLimit normalises pagination so a bad client can't ask for everything.
|
||||||
|
func clampLimit(limit int) int {
|
||||||
|
if limit <= 0 {
|
||||||
|
return 25
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns a page of users filtered by an optional query (matched
|
||||||
|
// against name, handle, or email), newest first, with the total count.
|
||||||
|
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
||||||
|
limit = clampLimit(limit)
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT id, email, handle, name, avatar_url, role, plan, email_verified_at, banned_at, created_at,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM users
|
||||||
|
WHERE ($1 = '' OR name ILIKE '%'||$1||'%' OR handle ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(email, '') ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("admin list users: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.AdminUser{}
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var u model.AdminUser
|
||||||
|
if err := rows.Scan(&u.ID, &u.Email, &u.Handle, &u.Name, &u.AvatarURL,
|
||||||
|
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.BannedAt, &u.CreatedAt, &total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan admin user: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, u)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserExists reports whether a user id is real (used before sending a
|
||||||
|
// notification to it).
|
||||||
|
func (r *AdminRepo) UserExists(ctx context.Context, id string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := r.pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, id).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin user exists: %w", err)
|
||||||
|
}
|
||||||
|
return exists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPosts returns a page of posts filtered by an optional query (caption,
|
||||||
|
// source title, or author handle), newest first, with the total count.
|
||||||
|
func (r *AdminRepo) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) {
|
||||||
|
limit = clampLimit(limit)
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT p.id, p.user_id, p.caption, p.source_title, p.source_url, p.is_hidden, p.deleted_at, p.created_at,
|
||||||
|
u.name, u.handle, p.likes_count,
|
||||||
|
(SELECT COUNT(*) FROM post_comments c WHERE c.post_id = p.id AND c.deleted_at IS NULL) AS comments_count,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM posts p
|
||||||
|
JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE ($1 = '' OR p.caption ILIKE '%'||$1||'%' OR p.source_title ILIKE '%'||$1||'%'
|
||||||
|
OR u.handle ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("admin list posts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.AdminPost{}
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var p model.AdminPost
|
||||||
|
if err := rows.Scan(&p.ID, &p.UserID, &p.Caption, &p.SourceTitle, &p.SourceURL,
|
||||||
|
&p.IsHidden, &p.DeletedAt, &p.CreatedAt, &p.AuthorName, &p.AuthorHandle,
|
||||||
|
&p.LikesCount, &p.CommentsCount, &total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan admin post: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCollections returns a page of collections filtered by an optional query
|
||||||
|
// (title, description, or owner handle), newest first, with the total count.
|
||||||
|
func (r *AdminRepo) ListCollections(ctx context.Context, q string, limit, offset int) ([]model.AdminCollection, int, error) {
|
||||||
|
limit = clampLimit(limit)
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT c.id, c.user_id, c.title, c.description, c.is_public, c.created_at,
|
||||||
|
(SELECT COUNT(*) FROM collection_items ci WHERE ci.collection_id = c.id) AS item_count,
|
||||||
|
u.name, u.handle,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM collections c
|
||||||
|
JOIN users u ON u.id = c.user_id
|
||||||
|
WHERE ($1 = '' OR c.title ILIKE '%'||$1||'%' OR COALESCE(c.description, '') ILIKE '%'||$1||'%'
|
||||||
|
OR u.handle ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("admin list collections: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.AdminCollection{}
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var c model.AdminCollection
|
||||||
|
if err := rows.Scan(&c.ID, &c.UserID, &c.Title, &c.Description, &c.IsPublic,
|
||||||
|
&c.CreatedAt, &c.ItemCount, &c.OwnerName, &c.OwnerHandle, &total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan admin collection: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFeedback returns a page of feedback. status is "all" | "open" |
|
||||||
|
// "resolved". Newest first, with the total count for the chosen filter.
|
||||||
|
func (r *AdminRepo) ListFeedback(ctx context.Context, status string, limit, offset int) ([]model.AdminFeedback, int, error) {
|
||||||
|
limit = clampLimit(limit)
|
||||||
|
if status != "open" && status != "resolved" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
|
SELECT f.id, f.category, f.message, f.contact_email, f.created_at,
|
||||||
|
f.resolved_at, f.replied_at, f.reply_message,
|
||||||
|
u.handle, u.name, u.email,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM feedback f
|
||||||
|
LEFT JOIN users u ON u.id = f.user_id
|
||||||
|
WHERE ($1 = 'all'
|
||||||
|
OR ($1 = 'open' AND f.resolved_at IS NULL)
|
||||||
|
OR ($1 = 'resolved' AND f.resolved_at IS NOT NULL))
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`, status, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("admin list feedback: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.AdminFeedback{}
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var f model.AdminFeedback
|
||||||
|
if err := rows.Scan(&f.ID, &f.Category, &f.Message, &f.ContactEmail, &f.CreatedAt,
|
||||||
|
&f.ResolvedAt, &f.RepliedAt, &f.ReplyMessage,
|
||||||
|
&f.AuthorHandle, &f.AuthorName, &f.AuthorEmail, &total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan admin feedback: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveReply stores an admin reply on a feedback row, stamps replied_at, and
|
||||||
|
// resolves it (if not already). It returns the recipient's email (the
|
||||||
|
// submitter's account email, else the anonymous contact_email — may be nil if
|
||||||
|
// neither is known) and name so the caller can send the reply email. ok is
|
||||||
|
// false when the id doesn't exist.
|
||||||
|
func (r *AdminRepo) SaveReply(ctx context.Context, id, message string) (email *string, name string, ok bool, err error) {
|
||||||
|
err = r.pg.Pool.QueryRow(ctx, `
|
||||||
|
UPDATE feedback f
|
||||||
|
SET reply_message = $2,
|
||||||
|
replied_at = now(),
|
||||||
|
resolved_at = COALESCE(resolved_at, now())
|
||||||
|
WHERE f.id = $1
|
||||||
|
RETURNING COALESCE((SELECT email FROM users WHERE id = f.user_id), f.contact_email),
|
||||||
|
COALESCE((SELECT name FROM users WHERE id = f.user_id), '')`,
|
||||||
|
id, message,
|
||||||
|
).Scan(&email, &name)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, fmt.Errorf("admin save feedback reply: %w", err)
|
||||||
|
}
|
||||||
|
return email, name, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReports returns a page of reports of one type ("post" | "comment" |
|
||||||
|
// "user"), unresolved first then newest, filtered by an optional query, with
|
||||||
|
// the total count.
|
||||||
|
func (r *AdminRepo) ListReports(ctx context.Context, reportType, q string, limit, offset int) ([]model.AdminReport, int, error) {
|
||||||
|
limit = clampLimit(limit)
|
||||||
|
var sql string
|
||||||
|
switch reportType {
|
||||||
|
case "post":
|
||||||
|
sql = `
|
||||||
|
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
||||||
|
ru.handle, ru.name,
|
||||||
|
r.post_id, p.caption, pu.handle,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM post_reports r
|
||||||
|
LEFT JOIN users ru ON ru.id = r.reporter_id
|
||||||
|
LEFT JOIN posts p ON p.id = r.post_id
|
||||||
|
LEFT JOIN users pu ON pu.id = p.user_id
|
||||||
|
WHERE ($1 = '' OR COALESCE(p.caption, '') ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(pu.handle, '') ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`
|
||||||
|
case "comment":
|
||||||
|
sql = `
|
||||||
|
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
||||||
|
ru.handle, ru.name,
|
||||||
|
r.comment_id, c.body, cu.handle,
|
||||||
|
COUNT(*) OVER() AS total
|
||||||
|
FROM comment_reports r
|
||||||
|
LEFT JOIN users ru ON ru.id = r.reporter_id
|
||||||
|
LEFT JOIN post_comments c ON c.id = r.comment_id
|
||||||
|
LEFT JOIN users cu ON cu.id = c.user_id
|
||||||
|
WHERE ($1 = '' OR COALESCE(c.body, '') ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(cu.handle, '') ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`
|
||||||
|
case "user":
|
||||||
|
sql = `
|
||||||
|
SELECT r.id, r.category, r.reason, r.created_at, r.resolved_at,
|
||||||
|
ru.handle, ru.name,
|
||||||
|
r.reported_id, tu.handle, tu.handle
|
||||||
|
, COUNT(*) OVER() AS total
|
||||||
|
FROM user_reports r
|
||||||
|
LEFT JOIN users ru ON ru.id = r.reporter_id
|
||||||
|
LEFT JOIN users tu ON tu.id = r.reported_id
|
||||||
|
WHERE ($1 = '' OR COALESCE(tu.handle, '') ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(tu.name, '') ILIKE '%'||$1||'%'
|
||||||
|
OR COALESCE(ru.handle, '') ILIKE '%'||$1||'%')
|
||||||
|
ORDER BY (r.resolved_at IS NOT NULL), r.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`
|
||||||
|
default:
|
||||||
|
return nil, 0, fmt.Errorf("unknown report type %q", reportType)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, sql, q, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("admin list %s reports: %w", reportType, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := []model.AdminReport{}
|
||||||
|
total := 0
|
||||||
|
for rows.Next() {
|
||||||
|
rep := model.AdminReport{Type: reportType}
|
||||||
|
if err := rows.Scan(&rep.ID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt,
|
||||||
|
&rep.ReporterHandle, &rep.ReporterName,
|
||||||
|
&rep.TargetID, &rep.TargetText, &rep.TargetAuthorHandle, &total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("scan admin report: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, rep)
|
||||||
|
}
|
||||||
|
return out, total, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveReport stamps resolved_at=now() on a still-open report of the given
|
||||||
|
// type. Idempotent and one-way (the WHERE guard). Returns true when this call
|
||||||
|
// is the one that resolved it.
|
||||||
|
func (r *AdminRepo) ResolveReport(ctx context.Context, reportType, id string) (bool, error) {
|
||||||
|
var table string
|
||||||
|
switch reportType {
|
||||||
|
case "post":
|
||||||
|
table = "post_reports"
|
||||||
|
case "comment":
|
||||||
|
table = "comment_reports"
|
||||||
|
case "user":
|
||||||
|
table = "user_reports"
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unknown report type %q", reportType)
|
||||||
|
}
|
||||||
|
// table is from a fixed whitelist above — safe to interpolate.
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
fmt.Sprintf(`UPDATE %s SET resolved_at = now() WHERE id = $1 AND resolved_at IS NULL`, table),
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin resolve %s report: %w", reportType, err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Moderation actions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SetPostHidden hides/unhides any post (admin override — not owner-scoped).
|
||||||
|
// Returns false if the id doesn't exist.
|
||||||
|
func (r *AdminRepo) SetPostHidden(ctx context.Context, postID string, hidden bool) (bool, error) {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`UPDATE posts SET is_hidden = $2, updated_at = now() WHERE id = $1`, postID, hidden)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin set post hidden: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPostDeleted soft-deletes or restores a post by stamping/clearing
|
||||||
|
// deleted_at. A soft-deleted post is filtered out of every read path (feeds,
|
||||||
|
// profile, hidden tab, collections, detail) but kept in the table so it can be
|
||||||
|
// restored. Returns false if the id doesn't exist.
|
||||||
|
func (r *AdminRepo) SetPostDeleted(ctx context.Context, postID string, deleted bool) (bool, error) {
|
||||||
|
var sql string
|
||||||
|
if deleted {
|
||||||
|
sql = `UPDATE posts SET deleted_at = now(), updated_at = now() WHERE id = $1`
|
||||||
|
} else {
|
||||||
|
sql = `UPDATE posts SET deleted_at = NULL, updated_at = now() WHERE id = $1`
|
||||||
|
}
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, sql, postID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin set post deleted: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCommentDeleted soft-deletes or restores a comment (same semantics as
|
||||||
|
// SetPostDeleted). Returns false if the id doesn't exist.
|
||||||
|
func (r *AdminRepo) SetCommentDeleted(ctx context.Context, commentID string, deleted bool) (bool, error) {
|
||||||
|
var sql string
|
||||||
|
if deleted {
|
||||||
|
sql = `UPDATE post_comments SET deleted_at = now(), updated_at = now() WHERE id = $1`
|
||||||
|
} else {
|
||||||
|
sql = `UPDATE post_comments SET deleted_at = NULL, updated_at = now() WHERE id = $1`
|
||||||
|
}
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, sql, commentID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin set comment deleted: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserBanned suspends/un-suspends an account. Banning also bumps
|
||||||
|
// token_version, which invalidates the user's existing JWTs so the lock-out is
|
||||||
|
// immediate (RequireAuth additionally rejects banned users). Returns false if
|
||||||
|
// the id doesn't exist.
|
||||||
|
func (r *AdminRepo) SetUserBanned(ctx context.Context, userID string, banned bool) (bool, error) {
|
||||||
|
var sql string
|
||||||
|
if banned {
|
||||||
|
sql = `UPDATE users SET banned_at = now(), token_version = token_version + 1, updated_at = now() WHERE id = $1`
|
||||||
|
} else {
|
||||||
|
sql = `UPDATE users SET banned_at = NULL, updated_at = now() WHERE id = $1`
|
||||||
|
}
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, sql, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("admin set user banned: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
@@ -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) {
|
func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]model.Post, error) {
|
||||||
rows, err := r.pg.Pool.Query(ctx, `
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
SELECT p.id, p.user_id, p.caption, p.source_url, COALESCE(p.source_title, ''),
|
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
|
FROM collection_items ci
|
||||||
JOIN posts p ON p.id = ci.post_id
|
JOIN posts p ON p.id = ci.post_id
|
||||||
JOIN users u ON u.id = p.user_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`,
|
ORDER BY ci.position, ci.added_at`,
|
||||||
collectionID,
|
collectionID,
|
||||||
)
|
)
|
||||||
@@ -227,7 +227,7 @@ func (r *CollectionRepo) PostsIn(ctx context.Context, collectionID string) ([]mo
|
|||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
&p.ID, &p.UserID, &p.Caption, &p.SourceURL, &p.SourceTitle,
|
||||||
&p.CreatedAt, &p.UpdatedAt,
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL, &p.IsHidden,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("scan post: %w", err)
|
return nil, fmt.Errorf("scan post: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrCommentNotFound — no comment with that id (or not in the expected
|
||||||
|
// scope, e.g. pinning a reply).
|
||||||
|
ErrCommentNotFound = errors.New("comment not found")
|
||||||
|
// ErrDuplicateComment — the same user re-posted identical text in the same
|
||||||
|
// scope (caught by the dedup unique index).
|
||||||
|
ErrDuplicateComment = errors.New("duplicate comment")
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommentRepo handles post comments, replies, comment-likes, and pinning.
|
||||||
|
// Comment likes are far lower-volume than post likes, so likes_count is kept
|
||||||
|
// in sync transactionally rather than batched through Redis.
|
||||||
|
type CommentRepo struct {
|
||||||
|
pg *Postgres
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCommentRepo(pg *Postgres, logger *zap.Logger) *CommentRepo {
|
||||||
|
return &CommentRepo{pg: pg, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullUUIDArg returns nil for an empty id so a `$n::uuid` parameter binds to
|
||||||
|
// SQL NULL (anonymous viewer) instead of failing uuid parsing on "".
|
||||||
|
func nullUUIDArg(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
// commentSelect is the shared column list for reading a comment with its
|
||||||
|
// author, the reply-to handle, the viewer's like state, and the reply count.
|
||||||
|
// `vl` is a viewer-scoped LEFT JOIN on comment_likes; `ru` the reply-to user.
|
||||||
|
const commentSelect = `
|
||||||
|
c.id, c.post_id, c.user_id, c.parent_id, c.body, c.likes_count,
|
||||||
|
c.pinned_at, c.created_at, c.updated_at,
|
||||||
|
u.name, u.handle, u.avatar_url,
|
||||||
|
ru.handle,
|
||||||
|
(vl.comment_id IS NOT NULL),
|
||||||
|
(SELECT count(*) FROM post_comments rc WHERE rc.parent_id = c.id AND rc.deleted_at IS NULL)`
|
||||||
|
|
||||||
|
// The viewer id always rides in parameter slot $2 across the read queries
|
||||||
|
// below, so the shared viewer-scoped like join can hardcode it.
|
||||||
|
const commentFrom = `
|
||||||
|
FROM post_comments c
|
||||||
|
JOIN users u ON u.id = c.user_id
|
||||||
|
LEFT JOIN users ru ON ru.id = c.reply_to_user_id
|
||||||
|
LEFT JOIN comment_likes vl ON vl.comment_id = c.id AND vl.user_id = $2::uuid`
|
||||||
|
|
||||||
|
func scanComment(row pgx.Row, c *model.Comment) error {
|
||||||
|
return row.Scan(&c.ID, &c.PostID, &c.UserID, &c.ParentID, &c.Body, &c.LikesCount,
|
||||||
|
&c.PinnedAt, &c.CreatedAt, &c.UpdatedAt,
|
||||||
|
&c.AuthorName, &c.AuthorHandle, &c.AuthorAvatarURL,
|
||||||
|
&c.ReplyToHandle,
|
||||||
|
&c.ViewerHasLiked, &c.ReplyCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanComments(rows pgx.Rows) ([]model.Comment, error) {
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.Comment{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c model.Comment
|
||||||
|
if err := scanComment(rows, &c); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan comment: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostAuthor returns the id of the user who owns the post, or ErrPostNotFound.
|
||||||
|
func (r *CommentRepo) PostAuthor(ctx context.Context, postID string) (string, error) {
|
||||||
|
var uid string
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `SELECT user_id FROM posts WHERE id = $1`, postID).Scan(&uid)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrPostNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("post author: %w", err)
|
||||||
|
}
|
||||||
|
return uid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a comment. parentID is the thread root (nil for a top-level
|
||||||
|
// comment); replyToUserID is who a reply addresses (nil for top-level).
|
||||||
|
// Returns ErrDuplicateComment when the same user re-posts identical text in
|
||||||
|
// the same scope. The returned Comment carries author fields.
|
||||||
|
func (r *CommentRepo) Create(ctx context.Context, postID, userID string, parentID, replyToUserID *string, body string) (*model.Comment, error) {
|
||||||
|
var id string
|
||||||
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO post_comments (post_id, user_id, parent_id, reply_to_user_id, body)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (post_id, user_id, COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), md5(body))
|
||||||
|
DO NOTHING
|
||||||
|
RETURNING id`,
|
||||||
|
postID, userID, parentID, replyToUserID, body,
|
||||||
|
).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrDuplicateComment
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert comment: %w", err)
|
||||||
|
}
|
||||||
|
// The author hasn't liked their own brand-new comment, so viewer state is
|
||||||
|
// resolved against userID (yields false) — fine for the create response.
|
||||||
|
return r.Get(ctx, id, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns one comment hydrated with author + viewer-like + reply count.
|
||||||
|
func (r *CommentRepo) Get(ctx context.Context, id, viewerID string) (*model.Comment, error) {
|
||||||
|
q := `SELECT ` + commentSelect + commentFrom + ` WHERE c.id = $1 AND c.deleted_at IS NULL`
|
||||||
|
c := &model.Comment{}
|
||||||
|
err := scanComment(r.pg.Pool.QueryRow(ctx, q, id, nullUUIDArg(viewerID)), c)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrCommentNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get comment: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTopLevel returns a page of a post's top-level comments — pinned first,
|
||||||
|
// then newest — plus the total top-level count.
|
||||||
|
func (r *CommentRepo) ListTopLevel(ctx context.Context, postID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
|
||||||
|
var total int
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM post_comments WHERE post_id = $1 AND parent_id IS NULL AND deleted_at IS NULL`,
|
||||||
|
postID).Scan(&total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("count comments: %w", err)
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return []model.Comment{}, 0, nil
|
||||||
|
}
|
||||||
|
q := `SELECT ` + commentSelect + commentFrom + `
|
||||||
|
WHERE c.post_id = $1 AND c.parent_id IS NULL AND c.deleted_at IS NULL
|
||||||
|
ORDER BY (c.pinned_at IS NOT NULL) DESC, c.pinned_at DESC NULLS LAST, c.created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4`
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, q, postID, nullUUIDArg(viewerID), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("list comments: %w", err)
|
||||||
|
}
|
||||||
|
out, err := scanComments(rows)
|
||||||
|
return out, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReplies returns a page of a thread's replies (oldest first) + the total.
|
||||||
|
func (r *CommentRepo) ListReplies(ctx context.Context, parentID, viewerID string, limit, offset int) ([]model.Comment, int, error) {
|
||||||
|
var total int
|
||||||
|
if err := r.pg.Pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM post_comments WHERE parent_id = $1 AND deleted_at IS NULL`, parentID).Scan(&total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("count replies: %w", err)
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return []model.Comment{}, 0, nil
|
||||||
|
}
|
||||||
|
q := `SELECT ` + commentSelect + commentFrom + `
|
||||||
|
WHERE c.parent_id = $1 AND c.deleted_at IS NULL
|
||||||
|
ORDER BY c.created_at ASC
|
||||||
|
LIMIT $3 OFFSET $4`
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, q, parentID, nullUUIDArg(viewerID), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("list replies: %w", err)
|
||||||
|
}
|
||||||
|
out, err := scanComments(rows)
|
||||||
|
return out, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hydrate fills each post's comment summary — its pinned comment (if any) and
|
||||||
|
// total comment count (top-level + replies) — in two batched queries for the
|
||||||
|
// page. Drives the card footer's pinned line and comment count.
|
||||||
|
func (r *CommentRepo) Hydrate(ctx context.Context, viewerID string, posts []*model.Post) error {
|
||||||
|
ids := make([]string, 0, len(posts))
|
||||||
|
for _, p := range posts {
|
||||||
|
if p != nil {
|
||||||
|
ids = append(ids, p.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned comment per post.
|
||||||
|
q := `SELECT ` + commentSelect + commentFrom + `
|
||||||
|
WHERE c.post_id = ANY($1) AND c.pinned_at IS NOT NULL`
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, q, ids, nullUUIDArg(viewerID))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hydrate pinned: %w", err)
|
||||||
|
}
|
||||||
|
list, err := scanComments(rows) // closes rows
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pinnedByPost := make(map[string]*model.Comment, len(list))
|
||||||
|
for i := range list {
|
||||||
|
pinnedByPost[list[i].PostID] = &list[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total comment count per post (top-level + replies).
|
||||||
|
counts := make(map[string]int, len(ids))
|
||||||
|
crows, err := r.pg.Pool.Query(ctx,
|
||||||
|
`SELECT post_id, count(*) FROM post_comments WHERE post_id = ANY($1) AND deleted_at IS NULL GROUP BY post_id`,
|
||||||
|
ids)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hydrate comment counts: %w", err)
|
||||||
|
}
|
||||||
|
defer crows.Close()
|
||||||
|
for crows.Next() {
|
||||||
|
var id string
|
||||||
|
var n int
|
||||||
|
if err := crows.Scan(&id, &n); err != nil {
|
||||||
|
return fmt.Errorf("scan comment count: %w", err)
|
||||||
|
}
|
||||||
|
counts[id] = n
|
||||||
|
}
|
||||||
|
if err := crows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range posts {
|
||||||
|
if p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c, ok := pinnedByPost[p.ID]; ok {
|
||||||
|
p.PinnedComment = c
|
||||||
|
}
|
||||||
|
p.CommentsCount = counts[p.ID]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like / Unlike toggle the viewer's like on a comment and return the new
|
||||||
|
// likes_count. Idempotent; the membership change and counter update happen in
|
||||||
|
// one transaction so concurrent likes stay consistent.
|
||||||
|
func (r *CommentRepo) Like(ctx context.Context, commentID, userID string) (int, error) {
|
||||||
|
return r.setLike(ctx, commentID, userID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CommentRepo) Unlike(ctx context.Context, commentID, userID string) (int, error) {
|
||||||
|
return r.setLike(ctx, commentID, userID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CommentRepo) setLike(ctx context.Context, commentID, userID string, like bool) (int, error) {
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("begin like txn: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// Lock the comment row up front: this validates existence (clean 404
|
||||||
|
// instead of a leaked FK violation) and serializes concurrent likes so
|
||||||
|
// the counter stays exact.
|
||||||
|
var count int
|
||||||
|
if err := tx.QueryRow(ctx,
|
||||||
|
`SELECT likes_count FROM post_comments WHERE id = $1 FOR UPDATE`, commentID).Scan(&count); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, ErrCommentNotFound
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("read comment likes_count: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if like {
|
||||||
|
tag, err := tx.Exec(ctx,
|
||||||
|
`INSERT INTO comment_likes (comment_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||||
|
commentID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert comment like: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() > 0 {
|
||||||
|
count++
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`UPDATE post_comments SET likes_count = $2 WHERE id = $1`, commentID, count); err != nil {
|
||||||
|
return 0, fmt.Errorf("bump comment likes: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tag, err := tx.Exec(ctx,
|
||||||
|
`DELETE FROM comment_likes WHERE comment_id = $1 AND user_id = $2`, commentID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("delete comment like: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() > 0 {
|
||||||
|
if count > 0 {
|
||||||
|
count--
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`UPDATE post_comments SET likes_count = $2 WHERE id = $1`, commentID, count); err != nil {
|
||||||
|
return 0, fmt.Errorf("drop comment likes: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return 0, fmt.Errorf("commit like txn: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin makes commentID the post's single pinned comment (top-level only),
|
||||||
|
// unpinning any previously pinned comment in the same transaction. Returns
|
||||||
|
// ErrCommentNotFound when the comment isn't a top-level comment of postID.
|
||||||
|
func (r *CommentRepo) Pin(ctx context.Context, postID, commentID string) error {
|
||||||
|
tx, err := r.pg.Pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin pin txn: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`UPDATE post_comments SET pinned_at = NULL WHERE post_id = $1 AND pinned_at IS NOT NULL`,
|
||||||
|
postID); err != nil {
|
||||||
|
return fmt.Errorf("unpin existing: %w", err)
|
||||||
|
}
|
||||||
|
tag, err := tx.Exec(ctx,
|
||||||
|
`UPDATE post_comments SET pinned_at = now(), updated_at = now()
|
||||||
|
WHERE id = $1 AND post_id = $2 AND parent_id IS NULL`,
|
||||||
|
commentID, postID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pin comment: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrCommentNotFound
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpin clears the pin on commentID for postID.
|
||||||
|
func (r *CommentRepo) Unpin(ctx context.Context, postID, commentID string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`UPDATE post_comments SET pinned_at = NULL WHERE id = $1 AND post_id = $2`,
|
||||||
|
commentID, postID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unpin comment: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrCommentNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the comment. For a thread root this cascades to its replies
|
||||||
|
// (and all comment_likes) via ON DELETE CASCADE. Authorization is the caller's
|
||||||
|
// responsibility.
|
||||||
|
func (r *CommentRepo) Delete(ctx context.Context, commentID string) error {
|
||||||
|
tag, err := r.pg.Pool.Exec(ctx, `DELETE FROM post_comments WHERE id = $1`, commentID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete comment: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrCommentNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReport files a report against a comment.
|
||||||
|
func (r *CommentRepo) CreateReport(ctx context.Context, reporterID, commentID, category, reason string) error {
|
||||||
|
if _, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO comment_reports (reporter_id, comment_id, category, reason)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
reporterID, commentID, category, reason,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("create comment report: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// feedSeenTTL bounds a home-feed "session": after this idle window the
|
||||||
|
// per-user seen-set expires and the feed starts fresh from the top.
|
||||||
|
const feedSeenTTL = 30 * time.Minute
|
||||||
|
|
||||||
|
// FeedRepo tracks, per user, which post ids have already been served in the
|
||||||
|
// current home-feed session (a Redis SET) so paginated requests never repeat
|
||||||
|
// a post — and so the viewer-state-dependent feed ordering can't cause an
|
||||||
|
// OFFSET to drift over already-served rows.
|
||||||
|
//
|
||||||
|
// Nil-safe: with no Redis configured every method degrades to a no-op /
|
||||||
|
// empty, and the feed falls back to plain offset paging.
|
||||||
|
type FeedRepo struct {
|
||||||
|
redis *Redis
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFeedRepo(redis *Redis, logger *zap.Logger) *FeedRepo {
|
||||||
|
return &FeedRepo{redis: redis, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether the Redis-backed seen-set is available.
|
||||||
|
func (r *FeedRepo) Enabled() bool { return r != nil && r.redis != nil }
|
||||||
|
|
||||||
|
func (r *FeedRepo) key(userID string) string { return "feed:seen:" + userID }
|
||||||
|
|
||||||
|
// Seen returns the post ids already served to the user this session (empty
|
||||||
|
// when Redis is unavailable or the session is fresh).
|
||||||
|
func (r *FeedRepo) Seen(ctx context.Context, userID string) []string {
|
||||||
|
if !r.Enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids, err := r.redis.Client.SMembers(ctx, r.key(userID)).Result()
|
||||||
|
if err != nil {
|
||||||
|
// Degrade rather than fail the feed — worst case is a possible repeat.
|
||||||
|
r.logger.Warn("feed seen: read failed", zap.String("user", userID), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkSeen records the ids just served and refreshes the session TTL.
|
||||||
|
func (r *FeedRepo) MarkSeen(ctx context.Context, userID string, ids []string) {
|
||||||
|
if !r.Enabled() || len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
members := make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
members[i] = id
|
||||||
|
}
|
||||||
|
pipe := r.redis.Client.Pipeline()
|
||||||
|
pipe.SAdd(ctx, r.key(userID), members...)
|
||||||
|
pipe.Expire(ctx, r.key(userID), feedSeenTTL)
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
r.logger.Warn("feed seen: write failed", zap.String("user", userID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears the user's seen-set — called when a fresh feed session begins
|
||||||
|
// (the first page request) so reopening the feed serves from the top again.
|
||||||
|
func (r *FeedRepo) Reset(ctx context.Context, userID string) {
|
||||||
|
if !r.Enabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.redis.Client.Del(ctx, r.key(userID)).Err(); err != nil {
|
||||||
|
r.logger.Warn("feed seen: reset failed", zap.String("user", userID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -36,7 +36,8 @@ const postSelectColumns = `
|
|||||||
p.reshared_from_post_id,
|
p.reshared_from_post_id,
|
||||||
ou.name AS original_author_name,
|
ou.name AS original_author_name,
|
||||||
ou.handle AS original_author_handle,
|
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 = `
|
const postSelectFrom = `
|
||||||
@@ -58,6 +59,7 @@ func scanPost(row interface {
|
|||||||
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
&p.AuthorName, &p.AuthorHandle, &p.AuthorAvatarURL,
|
||||||
&p.ResharedFromPostID,
|
&p.ResharedFromPostID,
|
||||||
&p.OriginalAuthorName, &p.OriginalAuthorHandle, &p.OriginalAuthorAvatar,
|
&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
|
// FindByID returns one post with its images and author info (plus original
|
||||||
// author info if this row is a reshare).
|
// author info if this row is a reshare).
|
||||||
func (r *PostRepo) FindByID(ctx context.Context, id string) (*model.Post, error) {
|
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{}
|
p := &model.Post{}
|
||||||
if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil {
|
if err := scanPost(r.pg.Pool.QueryRow(ctx, q, id), p); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -171,22 +173,178 @@ func (r *PostRepo) ListByHandle(ctx context.Context, handle string, limit, offse
|
|||||||
return r.ListByUser(ctx, userID, limit, offset)
|
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,
|
// ListByUser returns the user's profile feed — every post they own,
|
||||||
// authored or reshared. Reshares are interleaved naturally because
|
// authored or reshared. Reshares are interleaved naturally because
|
||||||
// they're stored as real rows; sort by created_at gets the right order
|
// they're stored as real rows; sort by created_at gets the right order
|
||||||
// without any UNION gymnastics.
|
// 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) {
|
func (r *PostRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||||
total, err := r.CountByUser(ctx, userID)
|
total, err := r.CountByUser(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
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
|
ORDER BY p.created_at DESC, p.id
|
||||||
LIMIT $2 OFFSET $3`
|
LIMIT $2 OFFSET $3`
|
||||||
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListHiddenByUser returns the owner's hidden posts, newest first — the
|
||||||
|
// /u/me "Hidden" tab. Owner-only (the caller enforces auth + scoping).
|
||||||
|
func (r *PostRepo) ListHiddenByUser(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||||
|
total, err := r.CountHiddenByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||||
|
WHERE p.user_id = $1 AND p.is_hidden = TRUE AND p.deleted_at IS NULL
|
||||||
|
ORDER BY p.created_at DESC, p.id
|
||||||
|
LIMIT $2 OFFSET $3`
|
||||||
|
return r.collectPosts(ctx, total, q, userID, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedForViewer returns one page of the global home feed for `viewerID`,
|
||||||
|
// newest-first within a two-tier priority:
|
||||||
|
//
|
||||||
|
// tier 0 — posts by people the viewer follows that they haven't liked yet
|
||||||
|
// tier 1 — everything else (followed-and-liked, followers', strangers')
|
||||||
|
//
|
||||||
|
// The viewer's own posts, anything involving a block (either direction), and
|
||||||
|
// any id in `exclude` (the already-served set) are filtered out. The tier is
|
||||||
|
// computed in ORDER BY via correlated EXISTS, so no extra columns leak into
|
||||||
|
// scanPost. No total is computed (infinite scroll infers "more" from a full
|
||||||
|
// page).
|
||||||
|
//
|
||||||
|
// With a non-empty `exclude` the caller drives pagination purely by exclusion
|
||||||
|
// (pass offset 0): since the tier depends on viewer state — liking a post
|
||||||
|
// moves it between tiers — an OFFSET would drift and repeat/skip rows, so the
|
||||||
|
// seen-set is the correct cursor. Without Redis, `exclude` is empty and OFFSET
|
||||||
|
// paginates (the frontend de-dups the rare drift). At larger scale this moves
|
||||||
|
// to a precomputed fan-out / keyset cursor.
|
||||||
|
func (r *PostRepo) FeedForViewer(ctx context.Context, viewerID string, limit, offset int, exclude []string) ([]model.Post, error) {
|
||||||
|
// nil would encode as SQL NULL, and `id <> ALL(NULL)` is NULL (excludes
|
||||||
|
// every row); a non-nil empty array means "exclude nothing".
|
||||||
|
if exclude == nil {
|
||||||
|
exclude = []string{}
|
||||||
|
}
|
||||||
|
q := `SELECT ` + postSelectColumns + postSelectFrom + `
|
||||||
|
WHERE p.user_id <> $1
|
||||||
|
AND p.is_hidden = FALSE AND p.deleted_at IS NULL
|
||||||
|
AND p.id <> ALL($4)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM user_blocks b
|
||||||
|
WHERE (b.blocker_id = $1 AND b.blocked_id = p.user_id)
|
||||||
|
OR (b.blocker_id = p.user_id AND b.blocked_id = $1)
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
(CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM follows f
|
||||||
|
WHERE f.follower_id = $1 AND f.following_id = p.user_id
|
||||||
|
AND f.status = 'accepted'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM post_likes pl
|
||||||
|
WHERE pl.post_id = p.id AND pl.user_id = $1
|
||||||
|
)
|
||||||
|
THEN 0 ELSE 1
|
||||||
|
END) ASC,
|
||||||
|
p.created_at DESC, p.id DESC
|
||||||
|
LIMIT $2 OFFSET $3`
|
||||||
|
posts, _, err := r.collectPosts(ctx, 0, q, viewerID, limit, offset, exclude)
|
||||||
|
return posts, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HydrateViewerRelationships fills ViewerFollowsAuthor / AuthorFollowsViewer
|
||||||
|
// across a page of feed posts in two batched queries (one per direction).
|
||||||
|
// No-op for anonymous viewers.
|
||||||
|
func (r *PostRepo) HydrateViewerRelationships(ctx context.Context, viewerID string, posts []model.Post) error {
|
||||||
|
if viewerID == "" || len(posts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
authorIDs := make([]string, 0, len(posts))
|
||||||
|
for i := range posts {
|
||||||
|
id := posts[i].UserID
|
||||||
|
if id != "" && id != viewerID && !seen[id] {
|
||||||
|
seen[id] = true
|
||||||
|
authorIDs = append(authorIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(authorIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
following, err := r.followEdges(ctx,
|
||||||
|
`SELECT following_id FROM follows
|
||||||
|
WHERE follower_id = $1 AND status = 'accepted' AND following_id = ANY($2)`,
|
||||||
|
viewerID, authorIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
followers, err := r.followEdges(ctx,
|
||||||
|
`SELECT follower_id FROM follows
|
||||||
|
WHERE following_id = $1 AND status = 'accepted' AND follower_id = ANY($2)`,
|
||||||
|
viewerID, authorIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range posts {
|
||||||
|
posts[i].ViewerFollowsAuthor = following[posts[i].UserID]
|
||||||
|
posts[i].AuthorFollowsViewer = followers[posts[i].UserID]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// followEdges runs a one-column follow query and collects the ids into a set.
|
||||||
|
func (r *PostRepo) followEdges(ctx context.Context, query, viewerID string, ids []string) (map[string]bool, error) {
|
||||||
|
rows, err := r.pg.Pool.Query(ctx, query, viewerID, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hydrate follow edges: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan follow edge: %w", err)
|
||||||
|
}
|
||||||
|
out[id] = true
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Delete removes a post only if it belongs to the caller. Cascades:
|
// Delete removes a post only if it belongs to the caller. Cascades:
|
||||||
// - the post's images (post_images.post_id FK)
|
// - the post's images (post_images.post_id FK)
|
||||||
// - any reshares of this post made by other users (posts.reshared_from_post_id FK)
|
// - any reshares of this post made by other users (posts.reshared_from_post_id FK)
|
||||||
@@ -203,12 +361,29 @@ func (r *PostRepo) Delete(ctx context.Context, postID, userID string) error {
|
|||||||
return nil
|
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).
|
// CountByUser returns the user's total post count (authored + reshared).
|
||||||
// Now just a single COUNT against posts.user_id.
|
// 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) {
|
func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := r.pg.Pool.QueryRow(ctx,
|
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)
|
).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("count posts: %w", err)
|
return 0, fmt.Errorf("count posts: %w", err)
|
||||||
@@ -216,6 +391,19 @@ func (r *PostRepo) CountByUser(ctx context.Context, userID string) (int, error)
|
|||||||
return n, nil
|
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`,
|
// 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
|
// 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
|
// to detect the "posted a quote today" discipline (and to score past days
|
||||||
|
|||||||
@@ -74,8 +74,9 @@ func (r *PushRepo) PruneToMostRecent(ctx context.Context, userID string, keep in
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteByEndpoint removes a subscription (on unsubscribe or when the push
|
// DeleteByEndpoint removes a subscription (when the push service reports it
|
||||||
// service reports it gone).
|
// 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 {
|
func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error {
|
||||||
_, err := r.pg.Pool.Exec(ctx,
|
_, err := r.pg.Pool.Exec(ctx,
|
||||||
`DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
|
`DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
|
||||||
@@ -84,3 +85,15 @@ func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,30 +44,14 @@ func (r *ReminderRepo) SetTimezone(ctx context.Context, userID, tz string) error
|
|||||||
return nil
|
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
|
// DueCandidates returns reminder-enabled users who haven't already had BOTH
|
||||||
// reminders today, with the data the worker needs to decide. Defaults are
|
// reminders today, with the data the worker needs to decide. Defaults are
|
||||||
// folded in (06:00 chant, 21:30 quote, UTC tz).
|
// 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) {
|
func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate, error) {
|
||||||
rows, err := r.pg.Pool.Query(ctx, `
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -92,7 +76,10 @@ func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate,
|
|||||||
COALESCE(u.daily_chanting_rounds, 16),
|
COALESCE(u.daily_chanting_rounds, 16),
|
||||||
u.last_chant_incomplete_reminder_on
|
u.last_chant_incomplete_reminder_on
|
||||||
FROM users u
|
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 (
|
AND (
|
||||||
u.last_chant_reminder_on IS NULL OR u.last_chant_reminder_on < (now() AT TIME ZONE 'UTC')::date
|
u.last_chant_reminder_on IS NULL OR u.last_chant_reminder_on < (now() AT TIME ZONE 'UTC')::date
|
||||||
OR u.last_quote_reminder_on IS NULL OR u.last_quote_reminder_on < (now() AT TIME ZONE 'UTC')::date
|
OR u.last_quote_reminder_on IS NULL OR u.last_quote_reminder_on < (now() AT TIME ZONE 'UTC')::date
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
@@ -314,17 +315,38 @@ func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *s
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateAdminMessage persists an admin → user direct message. Unlike the
|
||||||
|
// event-driven kinds it has no actor and carries free text in `body`.
|
||||||
|
func (r *NotificationRepo) CreateAdminMessage(ctx context.Context, userID, body string) error {
|
||||||
|
if _, err := r.pg.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO notifications (user_id, kind, body) VALUES ($1, 'admin_message', $2)`,
|
||||||
|
userID, body,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("create admin message notification: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListByUser returns a recipient's notifications, newest first, with the
|
// ListByUser returns a recipient's notifications, newest first, with the
|
||||||
// actor's display fields joined in for rendering (nil for system
|
// 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) {
|
func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
|
||||||
rows, err := r.pg.Pool.Query(ctx, `
|
rows, err := r.pg.Pool.Query(ctx, `
|
||||||
SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count,
|
SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count,
|
||||||
n.created_at, n.read_at,
|
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
|
FROM notifications n
|
||||||
LEFT JOIN users u ON u.id = n.actor_id
|
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
|
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
|
ORDER BY n.created_at DESC
|
||||||
LIMIT $2 OFFSET $3`, userID, limit, offset,
|
LIMIT $2 OFFSET $3`, userID, limit, offset,
|
||||||
)
|
)
|
||||||
@@ -337,7 +359,8 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
|
|||||||
var n model.Notification
|
var n model.Notification
|
||||||
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.PostID, &n.ActorCount,
|
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.PostID, &n.ActorCount,
|
||||||
&n.CreatedAt, &n.ReadAt,
|
&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)
|
return nil, fmt.Errorf("scan notification: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, n)
|
out = append(out, n)
|
||||||
@@ -347,32 +370,94 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
|
|||||||
|
|
||||||
// UpsertLike records (or aggregates) a like notification: one row per
|
// UpsertLike records (or aggregates) a like notification: one row per
|
||||||
// (recipient, post). On a repeat like it refreshes the most-recent actor,
|
// (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
|
// bumps actor_count, and re-marks the row unread + most-recent.
|
||||||
// new actor_count.
|
//
|
||||||
func (r *NotificationRepo) UpsertLike(ctx context.Context, userID, actorID, postID string) (int, error) {
|
// It also throttles the aggregated like *email*: like_email_sent_at is stamped
|
||||||
var count int
|
// to now() on the first like and again only once `throttle` has elapsed since
|
||||||
err := r.pg.Pool.QueryRow(ctx, `
|
// the last email. Returns the new actor_count and whether an email is due this
|
||||||
INSERT INTO notifications (user_id, actor_id, kind, post_id, actor_count)
|
// call (i.e. the stamp was (re)set now) — decided atomically in one statement
|
||||||
VALUES ($1, $2, 'post_like', $3, 1)
|
// 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'
|
ON CONFLICT (user_id, post_id) WHERE kind = 'post_like'
|
||||||
DO UPDATE SET actor_id = EXCLUDED.actor_id,
|
DO UPDATE SET actor_id = EXCLUDED.actor_id,
|
||||||
actor_count = notifications.actor_count + 1,
|
actor_count = notifications.actor_count + 1,
|
||||||
read_at = NULL,
|
read_at = NULL,
|
||||||
created_at = now()
|
created_at = now(),
|
||||||
RETURNING actor_count`,
|
like_email_sent_at = CASE
|
||||||
userID, actorID, postID,
|
WHEN notifications.like_email_sent_at IS NULL
|
||||||
).Scan(&count)
|
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 {
|
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) {
|
func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := r.pg.Pool.QueryRow(ctx,
|
err := r.pg.Pool.QueryRow(ctx, `
|
||||||
`SELECT count(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL`, userID,
|
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)
|
).Scan(&n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("count unread notifications: %w", err)
|
return 0, fmt.Errorf("count unread notifications: %w", err)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func NewUserRepo(pg *Postgres) *UserRepo {
|
|||||||
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
|
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
|
||||||
email_verified_at, token_version, created_at, updated_at, country,
|
email_verified_at, token_version, created_at, updated_at, country,
|
||||||
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
|
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
|
||||||
preferred_chanting_time, peak_active_time`
|
preferred_chanting_time, peak_active_time, banned_at`
|
||||||
|
|
||||||
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
|
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
|
||||||
// scanUser works for single-row and iterated reads alike.
|
// scanUser works for single-row and iterated reads alike.
|
||||||
@@ -50,7 +50,7 @@ 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.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.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
|
||||||
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
|
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
|
||||||
&u.PreferredChantingTime, &u.PeakActiveTime,
|
&u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,17 +321,35 @@ func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error
|
|||||||
// Postgres ILIKE is fine at our user-table scale (low six figures
|
// Postgres ILIKE is fine at our user-table scale (low six figures
|
||||||
// max). When the table grows enough to warrant fuzzy / trigram
|
// max). When the table grows enough to warrant fuzzy / trigram
|
||||||
// matching, add a pg_trgm GIN index and switch to similarity().
|
// 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), "@")
|
cleaned := strings.TrimPrefix(strings.TrimSpace(q), "@")
|
||||||
if cleaned == "" {
|
if cleaned == "" {
|
||||||
return []model.User{}, nil
|
return []model.User{}, 0, nil
|
||||||
}
|
}
|
||||||
if limit <= 0 || limit > 50 {
|
if limit <= 0 || limit > 50 {
|
||||||
limit = 20
|
limit = 20
|
||||||
}
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
substring := "%" + cleaned + "%"
|
substring := "%" + cleaned + "%"
|
||||||
prefix := 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 + `
|
sql := `SELECT ` + userColumns + `
|
||||||
FROM users
|
FROM users
|
||||||
WHERE name ILIKE $1 OR handle::text ILIKE $1
|
WHERE name ILIKE $1 OR handle::text ILIKE $1
|
||||||
@@ -341,19 +359,82 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit int) ([]model.Use
|
|||||||
WHEN name ILIKE $2 THEN 1
|
WHEN name ILIKE $2 THEN 1
|
||||||
ELSE 2
|
ELSE 2
|
||||||
END,
|
END,
|
||||||
name
|
name,
|
||||||
LIMIT $3`
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("search users: %w", err)
|
return nil, 0, fmt.Errorf("search users: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
out := []model.User{}
|
out := []model.User{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u model.User
|
var u model.User
|
||||||
if err := scanUser(rows, &u); err != nil {
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
out = append(out, u)
|
||||||
}
|
}
|
||||||
|
|||||||
+193
-12
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
chiMiddleware "github.com/go-chi/chi/v5/middleware"
|
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/mailer"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
"github.com/prabhupadasays/spsays-bff/pkg/oauth"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
"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"
|
"github.com/prabhupadasays/spsays-bff/pkg/shorturl"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -46,21 +49,25 @@ type Server struct {
|
|||||||
otherDocH *handler.OtherDocumentHandler
|
otherDocH *handler.OtherDocumentHandler
|
||||||
transcriptionH *handler.TranscriptionHandler
|
transcriptionH *handler.TranscriptionHandler
|
||||||
searchH *handler.SearchHandler
|
searchH *handler.SearchHandler
|
||||||
|
sitemapH *handler.SitemapHandler
|
||||||
shortURLH *handler.ShortURLHandler
|
shortURLH *handler.ShortURLHandler
|
||||||
authH *handler.AuthHandler
|
authH *handler.AuthHandler
|
||||||
oauthH *handler.OAuthHandler
|
oauthH *handler.OAuthHandler
|
||||||
userH *handler.UserHandler
|
userH *handler.UserHandler
|
||||||
postH *handler.PostHandler
|
postH *handler.PostHandler
|
||||||
|
commentH *handler.CommentHandler
|
||||||
bookmarkH *handler.BookmarkHandler
|
bookmarkH *handler.BookmarkHandler
|
||||||
collectionH *handler.CollectionHandler
|
collectionH *handler.CollectionHandler
|
||||||
profileH *handler.ProfileHandler
|
profileH *handler.ProfileHandler
|
||||||
relationH *handler.RelationshipHandler
|
relationH *handler.RelationshipHandler
|
||||||
|
soketiWebhookH *handler.SoketiWebhookHandler
|
||||||
notificationH *handler.NotificationHandler
|
notificationH *handler.NotificationHandler
|
||||||
sadhnaH *handler.SadhnaHandler
|
sadhnaH *handler.SadhnaHandler
|
||||||
pushH *handler.PushHandler
|
pushH *handler.PushHandler
|
||||||
reminderH *handler.ReminderHandler
|
reminderH *handler.ReminderHandler
|
||||||
feedbackH *handler.FeedbackHandler
|
feedbackH *handler.FeedbackHandler
|
||||||
statsH *handler.StatsHandler
|
statsH *handler.StatsHandler
|
||||||
|
adminH *handler.AdminHandler
|
||||||
|
|
||||||
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
// Auth state (nil when Postgres is unavailable — /auth and /users return 503)
|
||||||
authIssuer *auth.Issuer
|
authIssuer *auth.Issuer
|
||||||
@@ -87,6 +94,7 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
|
|||||||
magazineRepo := repository.NewMagazineRepo(mongoDB)
|
magazineRepo := repository.NewMagazineRepo(mongoDB)
|
||||||
otherDocRepo := repository.NewOtherDocumentRepo(mongoDB)
|
otherDocRepo := repository.NewOtherDocumentRepo(mongoDB)
|
||||||
transcriptionRepo := repository.NewTranscriptionRepo(mongoDB)
|
transcriptionRepo := repository.NewTranscriptionRepo(mongoDB)
|
||||||
|
sitemapRepo := repository.NewSitemapRepo(mongoDB)
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@@ -104,6 +112,7 @@ func New(cfg *config.Config, logger *zap.Logger) *Server {
|
|||||||
otherDocH: handler.NewOtherDocumentHandler(otherDocRepo),
|
otherDocH: handler.NewOtherDocumentHandler(otherDocRepo),
|
||||||
transcriptionH: handler.NewTranscriptionHandler(transcriptionRepo),
|
transcriptionH: handler.NewTranscriptionHandler(transcriptionRepo),
|
||||||
searchH: handler.NewSearchHandler(tsClient),
|
searchH: handler.NewSearchHandler(tsClient),
|
||||||
|
sitemapH: handler.NewSitemapHandler(sitemapRepo),
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL shortener: Postgres (canonical) + Redis (queue + hot cache) + background workers.
|
// URL shortener: Postgres (canonical) + Redis (queue + hot cache) + background workers.
|
||||||
@@ -156,6 +165,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
}, logger)
|
}, logger)
|
||||||
|
|
||||||
authSvc := service.NewAuthService(userRepo, tokenRepo, oauthRepo, issuer, mail, logger, cfg.FrontendURL)
|
authSvc := service.NewAuthService(userRepo, tokenRepo, oauthRepo, issuer, mail, logger, cfg.FrontendURL)
|
||||||
|
authSvc.SetRevalidator(revalidate.New(cfg.FrontendURL, cfg.RevalidateSecret, logger))
|
||||||
userSvc := service.NewUserService(userRepo, r2)
|
userSvc := service.NewUserService(userRepo, r2)
|
||||||
|
|
||||||
// Social repositories + services share the same Postgres pool and R2 client.
|
// Social repositories + services share the same Postgres pool and R2 client.
|
||||||
@@ -165,7 +175,15 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
// Likes: membership in Postgres, count delta batched through Redis
|
// Likes: membership in Postgres, count delta batched through Redis
|
||||||
// (s.redis may be nil — LikeRepo then applies counts directly).
|
// (s.redis may be nil — LikeRepo then applies counts directly).
|
||||||
likeRepo := repository.NewLikeRepo(s.pg, s.redis, logger)
|
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)
|
bookmarkSvc := service.NewBookmarkService(userRepo, bookmarkRepo)
|
||||||
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
|
collectionSvc := service.NewCollectionService(userRepo, collectionRepo, postRepo, r2)
|
||||||
|
|
||||||
@@ -212,16 +230,24 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
pushRepo := repository.NewPushRepo(s.pg)
|
pushRepo := repository.NewPushRepo(s.pg)
|
||||||
pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger)
|
pushSender := service.NewPushSender(pushRepo, cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubject, logger)
|
||||||
s.pushH = handler.NewPushHandler(pushSender, pushRepo)
|
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
|
// Wire the notification seam into the post service so a new like notifies
|
||||||
// the author (push + realtime; not the liker themselves).
|
// the author (push + realtime; not the liker themselves).
|
||||||
postSvc.SetNotifier(notifSvc)
|
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 +
|
// Sādhana reminders: a background worker nudges users (email + push +
|
||||||
// websocket) to chant / post a quote at their local time. Shares the
|
// websocket) to chant / post a quote at their local time. Shares the
|
||||||
// notification seam above so all three channels fire.
|
// notification seam above so all three channels fire.
|
||||||
reminderRepo := repository.NewReminderRepo(s.pg)
|
reminderRepo := repository.NewReminderRepo(s.pg)
|
||||||
reminderSvc := service.NewReminderService(reminderRepo, notifSvc, logger, cfg.FrontendURL)
|
reminderSvc := service.NewReminderService(reminderRepo, notifPrefsRepo, notifSvc, logger, cfg.FrontendURL)
|
||||||
s.reminderH = handler.NewReminderHandler(reminderSvc)
|
s.reminderH = handler.NewReminderHandler(reminderSvc)
|
||||||
wctx := s.workerCtx
|
wctx := s.workerCtx
|
||||||
if wctx == nil {
|
if wctx == nil {
|
||||||
@@ -237,7 +263,7 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
s.relationH = handler.NewRelationshipHandler(graphSvc)
|
s.relationH = handler.NewRelationshipHandler(graphSvc)
|
||||||
s.notificationH = handler.NewNotificationHandler(notifSvc)
|
s.notificationH = handler.NewNotificationHandler(notifSvc)
|
||||||
s.postH = handler.NewPostHandler(postSvc, graphSvc)
|
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
|
// Sādhana streak: daily chanting log + the growing-plant state. Reads
|
||||||
// the chanting half from sadhna_logs and the posting half from the
|
// the chanting half from sadhna_logs and the posting half from the
|
||||||
@@ -259,6 +285,13 @@ func (s *Server) initAuth(cfg *config.Config, logger *zap.Logger) {
|
|||||||
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
|
feedbackSvc := service.NewFeedbackService(feedbackRepo, mail, cfg.FeedbackAdminEmails, logger)
|
||||||
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
|
s.feedbackH = handler.NewFeedbackHandler(feedbackSvc)
|
||||||
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo, feedbackSvc)
|
s.statsH = handler.NewStatsHandler(statsRepo, feedbackRepo, feedbackSvc)
|
||||||
|
|
||||||
|
// Admin panel — read-mostly listing + notify/reply/resolve actions.
|
||||||
|
// Reuses the notification service (delivery) and mailer (feedback reply).
|
||||||
|
// Gated by RequireAuth + RequireAdmin in the router.
|
||||||
|
adminRepo := repository.NewAdminRepo(s.pg)
|
||||||
|
adminSvc := service.NewAdminService(adminRepo, notifSvc, mail, logger, cfg.FrontendURL)
|
||||||
|
s.adminH = handler.NewAdminHandler(adminSvc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
// initShortener wires Postgres + Redis + the shorturl handler/workers.
|
||||||
@@ -324,6 +357,29 @@ func (s *Server) Close() {
|
|||||||
s.logger.Info("all connections closed")
|
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
|
// 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
|
// auth handlers couldn't be wired up. Keeps every /auth/* and /users/* URL
|
||||||
// returning a structured error rather than 404.
|
// returning a structured error rather than 404.
|
||||||
@@ -356,9 +412,17 @@ func (s *Server) setupRouter() {
|
|||||||
r.Use(chiMiddleware.Recoverer)
|
r.Use(chiMiddleware.Recoverer)
|
||||||
r.Use(chiMiddleware.Compress(5))
|
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{
|
r.Use(cors.Handler(cors.Options{
|
||||||
AllowedOrigins: []string{s.cfg.FrontendURL},
|
AllowedOrigins: allowedOrigins,
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"},
|
||||||
ExposedHeaders: []string{"X-Request-ID"},
|
ExposedHeaders: []string{"X-Request-ID"},
|
||||||
@@ -387,26 +451,38 @@ func (s *Server) setupRouter() {
|
|||||||
|
|
||||||
// API v1
|
// API v1
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
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
|
// Books
|
||||||
r.Route("/books", func(r chi.Router) {
|
r.Route("/books", func(r chi.Router) {
|
||||||
r.Get("/", s.bookH.ListBooks)
|
r.Get("/", s.bookH.ListBooks)
|
||||||
|
|
||||||
// --- Verse books with divisions ---
|
// --- 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.Route("/sb", func(r chi.Router) {
|
||||||
r.Use(injectBookCode("sb"))
|
r.Use(injectBookCode("sb"))
|
||||||
r.Get("/", s.bookH.GetBook)
|
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}", s.bookH.GetDivisionInfo)
|
||||||
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
|
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
|
||||||
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
|
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
|
||||||
r.Get("/{division}/{chapter}/{verse}", s.bookH.GetDivisionVerse)
|
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.Route("/cc", func(r chi.Router) {
|
||||||
r.Use(injectBookCode("cc"))
|
r.Use(injectBookCode("cc"))
|
||||||
r.Get("/", s.bookH.GetBook)
|
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}", s.bookH.GetDivisionInfo)
|
||||||
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
|
r.Get("/{division}/{chapter}", s.bookH.GetDivisionChapterVerseIndex)
|
||||||
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
|
r.Get("/{division}/{chapter}/verses", s.bookH.GetDivisionChapterVerses)
|
||||||
@@ -414,8 +490,8 @@ func (s *Server) setupRouter() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// --- Verse books without divisions ---
|
// --- Verse books without divisions ---
|
||||||
// BG, SB1962: pure verse books
|
// SB1962: pure verse book
|
||||||
for _, code := range []string{"bg", "sb1962"} {
|
for _, code := range []string{"sb1962"} {
|
||||||
code := code // capture for closure
|
code := code // capture for closure
|
||||||
r.Route("/"+code, func(r chi.Router) {
|
r.Route("/"+code, func(r chi.Router) {
|
||||||
r.Use(injectBookCode(code))
|
r.Use(injectBookCode(code))
|
||||||
@@ -427,8 +503,9 @@ func (s *Server) setupRouter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Verse + prose intro books (no division) ---
|
// --- Verse + prose intro books (no division) ---
|
||||||
// BS, MM, NBS: have both verses and prose intro segments
|
// BG, BS, MM, NBS: verses plus prose front-matter segments
|
||||||
for _, code := range []string{"bs", "mm", "nbs"} {
|
// (the static /chapters routes take precedence over /{chapter}).
|
||||||
|
for _, code := range []string{"bg", "bs", "mm", "nbs"} {
|
||||||
code := code
|
code := code
|
||||||
r.Route("/"+code, func(r chi.Router) {
|
r.Route("/"+code, func(r chi.Router) {
|
||||||
r.Use(injectBookCode(code))
|
r.Use(injectBookCode(code))
|
||||||
@@ -643,6 +720,12 @@ func (s *Server) setupRouter() {
|
|||||||
r.Get("/{id}", s.postH.Get)
|
r.Get("/{id}", s.postH.Get)
|
||||||
// Paginated "liked by" list — public, like the count itself.
|
// Paginated "liked by" list — public, like the count itself.
|
||||||
r.Get("/{id}/likes", s.postH.ListLikes)
|
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" /
|
// Protected — create / delete / image presign / "my posts" /
|
||||||
// reshare actions.
|
// reshare actions.
|
||||||
@@ -650,8 +733,12 @@ func (s *Server) setupRouter() {
|
|||||||
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
r.Post("/", s.postH.Create)
|
r.Post("/", s.postH.Create)
|
||||||
r.Delete("/{id}", s.postH.Delete)
|
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.Put("/{id}/images", s.postH.ReplaceImages)
|
||||||
r.Get("/mine", s.postH.ListMine)
|
r.Get("/mine", s.postH.ListMine)
|
||||||
|
r.Get("/hidden", s.postH.ListHidden)
|
||||||
r.Post("/image-presign", s.postH.ImagePresign)
|
r.Post("/image-presign", s.postH.ImagePresign)
|
||||||
// Reshare is auth-required: anonymous users can't claim
|
// Reshare is auth-required: anonymous users can't claim
|
||||||
// to have reshared anything. The action is idempotent so
|
// to have reshared anything. The action is idempotent so
|
||||||
@@ -662,8 +749,43 @@ func (s *Server) setupRouter() {
|
|||||||
// count is batched into likes_count via Redis.
|
// count is batched into likes_count via Redis.
|
||||||
r.Post("/{id}/like", s.postH.Like)
|
r.Post("/{id}/like", s.postH.Like)
|
||||||
r.Delete("/{id}/like", s.postH.Unlike)
|
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.
|
// Report a post (category + optional reason) → admin email.
|
||||||
r.Post("/{id}/report", s.postH.Report)
|
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 +835,30 @@ func (s *Server) setupRouter() {
|
|||||||
// present, so the feed can carry `viewer_has_reshared` on each
|
// present, so the feed can carry `viewer_has_reshared` on each
|
||||||
// post. Without a cookie the routes still respond — they just
|
// post. Without a cookie the routes still respond — they just
|
||||||
// return the anonymous shape (all-false).
|
// return the anonymous shape (all-false).
|
||||||
|
// Soketi webhooks — server-to-server, authenticated by the HMAC
|
||||||
|
// signature (not a session). channel_vacated on a per-user
|
||||||
|
// notifications channel evicts that user's feed seen-set.
|
||||||
|
if s.soketiWebhookH != nil {
|
||||||
|
r.Post("/webhooks/soketi", s.soketiWebhookH.Handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home feed — the global, personalised feed of posts + its "new on
|
||||||
|
// the platform" discovery strip. Auth-required (the feed is
|
||||||
|
// per-viewer: ordering + relationship flags depend on the caller).
|
||||||
|
r.Route("/feed", func(r chi.Router) {
|
||||||
|
if s.postH == nil {
|
||||||
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Get("/", s.postH.Feed)
|
||||||
|
r.Get("/new-users", s.postH.FeedNewUsers)
|
||||||
|
// Clear the per-user seen-set (logout / page unload).
|
||||||
|
r.Post("/reset", s.postH.FeedReset)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
r.Route("/u", func(r chi.Router) {
|
r.Route("/u", func(r chi.Router) {
|
||||||
if s.profileH == nil {
|
if s.profileH == nil {
|
||||||
r.HandleFunc("/*", s.authUnavailable)
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
@@ -725,6 +871,7 @@ func (s *Server) setupRouter() {
|
|||||||
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
r.Use(middleware.OptionalAuth(s.authIssuer, s.userRepo))
|
||||||
r.Get("/{handle}", s.profileH.Get)
|
r.Get("/{handle}", s.profileH.Get)
|
||||||
r.Get("/{handle}/feed", s.profileH.Feed)
|
r.Get("/{handle}/feed", s.profileH.Feed)
|
||||||
|
r.Get("/{handle}/mentions", s.profileH.MentionsFeed)
|
||||||
r.Get("/{handle}/followers", s.profileH.Followers)
|
r.Get("/{handle}/followers", s.profileH.Followers)
|
||||||
r.Get("/{handle}/following", s.profileH.Following)
|
r.Get("/{handle}/following", s.profileH.Following)
|
||||||
})
|
})
|
||||||
@@ -791,6 +938,12 @@ func (s *Server) setupRouter() {
|
|||||||
r.Get("/", s.notificationH.List)
|
r.Get("/", s.notificationH.List)
|
||||||
r.Get("/unread-count", s.notificationH.UnreadCount)
|
r.Get("/unread-count", s.notificationH.UnreadCount)
|
||||||
r.Post("/read", s.notificationH.MarkAllRead)
|
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.
|
// --- Web Push --- VAPID key + per-user subscription management.
|
||||||
@@ -824,6 +977,34 @@ func (s *Server) setupRouter() {
|
|||||||
r.Post("/timezone", s.reminderH.SetTimezone)
|
r.Post("/timezone", s.reminderH.SetTimezone)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Admin panel --- every route gated by RequireAuth + RequireAdmin
|
||||||
|
// (role == "admin"), so the whole surface is invisible to ordinary
|
||||||
|
// accounts. Additive: no existing route or payload changes.
|
||||||
|
r.Route("/admin", func(r chi.Router) {
|
||||||
|
if s.adminH == nil {
|
||||||
|
r.HandleFunc("/*", s.authUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Use(middleware.RequireAuth(s.authIssuer, s.userRepo))
|
||||||
|
r.Use(middleware.RequireAdmin(s.userRepo))
|
||||||
|
r.Get("/users", s.adminH.ListUsers)
|
||||||
|
r.Post("/users/{id}/notify", s.adminH.NotifyUser)
|
||||||
|
r.Post("/users/{id}/ban", s.adminH.BanUser)
|
||||||
|
r.Delete("/users/{id}/ban", s.adminH.UnbanUser)
|
||||||
|
r.Get("/posts", s.adminH.ListPosts)
|
||||||
|
r.Post("/posts/{id}/hide", s.adminH.HidePost)
|
||||||
|
r.Delete("/posts/{id}/hide", s.adminH.UnhidePost)
|
||||||
|
r.Delete("/posts/{id}", s.adminH.DeletePost)
|
||||||
|
r.Post("/posts/{id}/restore", s.adminH.RestorePost)
|
||||||
|
r.Delete("/comments/{id}", s.adminH.DeleteComment)
|
||||||
|
r.Post("/comments/{id}/restore", s.adminH.RestoreComment)
|
||||||
|
r.Get("/collections", s.adminH.ListCollections)
|
||||||
|
r.Get("/feedback", s.adminH.ListFeedback)
|
||||||
|
r.Post("/feedback/{id}/reply", s.adminH.ReplyFeedback)
|
||||||
|
r.Get("/reports", s.adminH.ListReports)
|
||||||
|
r.Post("/reports/{type}/{id}/resolve", s.adminH.ResolveReport)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
s.router = r
|
s.router = r
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrAdminUserNotFound is returned by NotifyUser when the target id is unknown.
|
||||||
|
var ErrAdminUserNotFound = errors.New("user not found")
|
||||||
|
|
||||||
|
// AdminService backs the admin panel: read-mostly listing plus two actions
|
||||||
|
// (notify a user, reply to feedback) and report resolution. It reuses the
|
||||||
|
// existing NotificationService for delivery and the shared mailer for the
|
||||||
|
// feedback-reply email — no new infrastructure.
|
||||||
|
type AdminService struct {
|
||||||
|
repo *repository.AdminRepo
|
||||||
|
notif *NotificationService
|
||||||
|
mail mailer.Mailer
|
||||||
|
logger *zap.Logger
|
||||||
|
frontendURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminService(repo *repository.AdminRepo, notif *NotificationService, mail mailer.Mailer, logger *zap.Logger, frontendURL string) *AdminService {
|
||||||
|
return &AdminService{repo: repo, notif: notif, mail: mail, logger: logger, frontendURL: frontendURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) ListUsers(ctx context.Context, q string, limit, offset int) ([]model.AdminUser, int, error) {
|
||||||
|
return s.repo.ListUsers(ctx, q, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) ListPosts(ctx context.Context, q string, limit, offset int) ([]model.AdminPost, int, error) {
|
||||||
|
return s.repo.ListPosts(ctx, q, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) ListCollections(ctx context.Context, q string, limit, offset int) ([]model.AdminCollection, int, error) {
|
||||||
|
return s.repo.ListCollections(ctx, q, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) ListFeedback(ctx context.Context, status string, limit, offset int) ([]model.AdminFeedback, int, error) {
|
||||||
|
return s.repo.ListFeedback(ctx, status, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) ListReports(ctx context.Context, reportType, q string, limit, offset int) ([]model.AdminReport, int, error) {
|
||||||
|
return s.repo.ListReports(ctx, reportType, q, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyUser sends a free-text admin message to one user (in-app + push +
|
||||||
|
// email). Returns ErrAdminUserNotFound if the id doesn't exist.
|
||||||
|
func (s *AdminService) NotifyUser(ctx context.Context, userID, title, body string) error {
|
||||||
|
exists, err := s.repo.UserExists(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return ErrAdminUserNotFound
|
||||||
|
}
|
||||||
|
return s.notif.NotifyAdminMessage(ctx, userID, title, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplyFeedback stores the reply (resolving the row) and emails it to the
|
||||||
|
// submitter when an address is known. Returns found=false if the id is
|
||||||
|
// unknown, and emailed=true when a reply email was actually sent.
|
||||||
|
func (s *AdminService) ReplyFeedback(ctx context.Context, id, message string) (found, emailed bool, err error) {
|
||||||
|
email, name, ok, err := s.repo.SaveReply(ctx, id, message)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return ok, false, err
|
||||||
|
}
|
||||||
|
if email == nil || strings.TrimSpace(*email) == "" {
|
||||||
|
// Reply stored, but the submitter was anonymous with no contact email.
|
||||||
|
return true, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := feedbackReplyEmailTpl.Execute(&buf, map[string]any{
|
||||||
|
"RecipientName": name,
|
||||||
|
"Reply": message,
|
||||||
|
"URL": s.frontendURL,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("render feedback reply email failed", zap.Error(err))
|
||||||
|
return true, false, nil
|
||||||
|
}
|
||||||
|
if err := s.mail.Send(strings.TrimSpace(*email), "Re: your feedback — Prabhupāda Says", buf.String()); err != nil {
|
||||||
|
s.logger.Warn("feedback reply email send failed", zap.Error(err))
|
||||||
|
return true, false, nil
|
||||||
|
}
|
||||||
|
return true, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveReport stamps a report resolved. found=true when this call resolved a
|
||||||
|
// previously-open report (idempotent: a no-op on an already-resolved/unknown id
|
||||||
|
// returns false).
|
||||||
|
func (s *AdminService) ResolveReport(ctx context.Context, reportType, id string) (bool, error) {
|
||||||
|
return s.repo.ResolveReport(ctx, reportType, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Moderation actions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// SetPostHidden hides or unhides any post (found=false if unknown id).
|
||||||
|
func (s *AdminService) SetPostHidden(ctx context.Context, postID string, hidden bool) (bool, error) {
|
||||||
|
return s.repo.SetPostHidden(ctx, postID, hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePost soft-deletes a post (hidden from all reads, restorable).
|
||||||
|
func (s *AdminService) DeletePost(ctx context.Context, postID string) (bool, error) {
|
||||||
|
return s.repo.SetPostDeleted(ctx, postID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestorePost reverses a soft-delete.
|
||||||
|
func (s *AdminService) RestorePost(ctx context.Context, postID string) (bool, error) {
|
||||||
|
return s.repo.SetPostDeleted(ctx, postID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteComment soft-deletes a comment (restorable).
|
||||||
|
func (s *AdminService) DeleteComment(ctx context.Context, commentID string) (bool, error) {
|
||||||
|
return s.repo.SetCommentDeleted(ctx, commentID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreComment reverses a soft-delete.
|
||||||
|
func (s *AdminService) RestoreComment(ctx context.Context, commentID string) (bool, error) {
|
||||||
|
return s.repo.SetCommentDeleted(ctx, commentID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserBanned suspends or restores an account.
|
||||||
|
func (s *AdminService) SetUserBanned(ctx context.Context, userID string, banned bool) (bool, error) {
|
||||||
|
return s.repo.SetUserBanned(ctx, userID, banned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedbackReplyEmailTpl renders the reply email. Reply text is auto-escaped by
|
||||||
|
// html/template, so admin-entered text can't inject markup.
|
||||||
|
var feedbackReplyEmailTpl = template.Must(template.New("feedbackReply").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
|
||||||
|
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
|
||||||
|
|
||||||
|
<p>Thank you for writing to us. Here's our reply to your feedback:</p>
|
||||||
|
|
||||||
|
<blockquote style="white-space:pre-line;border-left:3px solid #9a6b2f;padding-left:1em;margin:1em 0;color:#3a3024">{{.Reply}}</blockquote>
|
||||||
|
|
||||||
|
<p style="margin:1.25em 0">
|
||||||
|
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open Prabhupāda Says</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin-top:1em">Your servants,<br>
|
||||||
|
<strong>The Prabhupāda Says team</strong></p>
|
||||||
|
</div>`))
|
||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
"github.com/prabhupadasays/spsays-bff/pkg/auth"
|
||||||
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
"github.com/prabhupadasays/spsays-bff/pkg/mailer"
|
||||||
|
"github.com/prabhupadasays/spsays-bff/pkg/revalidate"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
@@ -34,6 +35,7 @@ var (
|
|||||||
ErrEmailAlreadyVerified = errors.New("email already verified")
|
ErrEmailAlreadyVerified = errors.New("email already verified")
|
||||||
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
ErrWeakPassword = errors.New("password must be at least 8 characters")
|
||||||
ErrHandleTaken = errors.New("handle already in use")
|
ErrHandleTaken = errors.New("handle already in use")
|
||||||
|
ErrAccountSuspended = errors.New("this account has been suspended")
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthService bundles every flow that mutates auth state.
|
// AuthService bundles every flow that mutates auth state.
|
||||||
@@ -45,12 +47,17 @@ type AuthService struct {
|
|||||||
mail mailer.Mailer
|
mail mailer.Mailer
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
frontend string // FrontendURL — used to build reset / verify links
|
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.
|
// TTLs — surfaced as fields so tests can shrink them.
|
||||||
PasswordResetTTL time.Duration
|
PasswordResetTTL time.Duration
|
||||||
EmailVerifyTTL 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
|
// NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if
|
||||||
// the OAuth flows aren't wired (handler then returns 503).
|
// the OAuth flows aren't wired (handler then returns 503).
|
||||||
func NewAuthService(
|
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))
|
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)
|
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 {
|
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||||
return nil, ErrInvalidCredentials
|
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
|
// 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
|
// 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 {
|
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||||
return nil, ErrInvalidCredentials
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
if u.BannedAt != nil {
|
||||||
|
return nil, ErrAccountSuspended
|
||||||
|
}
|
||||||
|
|
||||||
otpHash := hashToken(otp)
|
otpHash := hashToken(otp)
|
||||||
tok, err := s.tokens.FindValidForUser(ctx, u.ID, otpHash, model.PurposeLoginOTP)
|
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 {
|
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// New public profile → refresh the sitemap so it's crawlable promptly.
|
||||||
|
s.reval.Trigger("sitemap")
|
||||||
return s.issueSession(u)
|
return s.issueSession(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -15,9 +15,12 @@ package service
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
@@ -31,6 +34,10 @@ const (
|
|||||||
NotifFollowRequest = "follow_request"
|
NotifFollowRequest = "follow_request"
|
||||||
NotifFollowAccepted = "follow_accepted"
|
NotifFollowAccepted = "follow_accepted"
|
||||||
NotifPostLike = "post_like"
|
NotifPostLike = "post_like"
|
||||||
|
NotifPostMention = "post_mention"
|
||||||
|
NotifPostComment = "post_comment"
|
||||||
|
NotifCommentReply = "comment_reply"
|
||||||
|
NotifCommentMention = "comment_mention"
|
||||||
|
|
||||||
// Reminder kinds are delivered live (email + push + a websocket toast)
|
// Reminder kinds are delivered live (email + push + a websocket toast)
|
||||||
// but NOT persisted — they're ephemeral nudges, not history.
|
// but NOT persisted — they're ephemeral nudges, not history.
|
||||||
@@ -39,8 +46,23 @@ const (
|
|||||||
NotifChantIncomplete = "chant_incomplete_reminder"
|
NotifChantIncomplete = "chant_incomplete_reminder"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PrefCategories groups notification kinds into the coarser event categories
|
||||||
|
// the Settings matrix exposes — storage stays kind-level (so backfilled
|
||||||
|
// opt-outs map exactly), but the UI reads/writes whole categories.
|
||||||
|
var PrefCategories = map[string][]string{
|
||||||
|
"follows": {NotifFollowRequest, NotifFollowAccepted},
|
||||||
|
"post_interactions": {NotifPostLike, NotifPostMention, NotifPostComment, NotifCommentReply},
|
||||||
|
"sadhna_reminders": {NotifChantReminder, NotifQuoteReminder, NotifChantIncomplete},
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrefChannels is every delivery channel a matrix cell can address.
|
||||||
|
var PrefChannels = []string{
|
||||||
|
repository.ChannelInApp, repository.ChannelEmail, repository.ChannelPush,
|
||||||
|
}
|
||||||
|
|
||||||
type NotificationService struct {
|
type NotificationService struct {
|
||||||
notifs *repository.NotificationRepo
|
notifs *repository.NotificationRepo
|
||||||
|
prefs *repository.NotificationPrefsRepo
|
||||||
users *repository.UserRepo
|
users *repository.UserRepo
|
||||||
mail mailer.Mailer
|
mail mailer.Mailer
|
||||||
rt *realtime.SoketiClient // optional — nil disables WebSocket push
|
rt *realtime.SoketiClient // optional — nil disables WebSocket push
|
||||||
@@ -49,9 +71,10 @@ type NotificationService struct {
|
|||||||
frontendURL string
|
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{
|
return &NotificationService{
|
||||||
notifs: notifs,
|
notifs: notifs,
|
||||||
|
prefs: prefs,
|
||||||
users: users,
|
users: users,
|
||||||
mail: mail,
|
mail: mail,
|
||||||
rt: rt,
|
rt: rt,
|
||||||
@@ -61,6 +84,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.
|
// List returns the caller's notifications, newest first.
|
||||||
func (s *NotificationService) List(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
|
func (s *NotificationService) List(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
|
||||||
if limit <= 0 || limit > 100 {
|
if limit <= 0 || limit > 100 {
|
||||||
@@ -83,6 +127,97 @@ func (s *NotificationService) MarkAllRead(ctx context.Context, userID string) er
|
|||||||
return s.notifs.MarkAllRead(ctx, userID)
|
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
|
// NotifyFollowRequest tells `recipientID` that `actorID` asked to follow
|
||||||
// them. Persists the row and emails the recipient (if they have an email
|
// them. Persists the row and emails the recipient (if they have an email
|
||||||
// on file).
|
// on file).
|
||||||
@@ -107,13 +242,16 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
|
|||||||
if authorID == "" || likerID == "" || authorID == likerID {
|
if authorID == "" || likerID == "" || authorID == likerID {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Aggregate into the single (author, post) like-notification row, getting
|
off := s.disabledPrefs(ctx, authorID)
|
||||||
// back how many likers it now represents.
|
|
||||||
count, err := s.notifs.UpsertLike(ctx, authorID, likerID, postID)
|
// 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 {
|
if err != nil {
|
||||||
s.logger.Warn("aggregate like notification failed",
|
s.logger.Warn("aggregate like notification failed",
|
||||||
zap.String("recipient", authorID), zap.Error(err))
|
zap.String("recipient", authorID), zap.Error(err))
|
||||||
count = 1
|
count, emailDue = 1, false
|
||||||
}
|
}
|
||||||
|
|
||||||
actorUser, _ := s.users.FindByID(ctx, likerID)
|
actorUser, _ := s.users.FindByID(ctx, likerID)
|
||||||
@@ -124,7 +262,7 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
|
|||||||
body := likeMessage(who, count)
|
body := likeMessage(who, count)
|
||||||
|
|
||||||
// Realtime nudge — carry actor_count so the toast can read "and N others".
|
// 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}
|
payload := map[string]any{"kind": NotifPostLike, "actor_count": count}
|
||||||
if actorUser != nil {
|
if actorUser != nil {
|
||||||
payload["actor_name"] = actorDisplay(actorUser)
|
payload["actor_name"] = actorDisplay(actorUser)
|
||||||
@@ -136,24 +274,167 @@ func (s *NotificationService) NotifyPostLike(ctx context.Context, postID, author
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web Push — tapping opens the author's own feed anchored to the post.
|
// Tapping a notification opens the author's own feed anchored to the post.
|
||||||
// Tagged per-post so repeated likes collapse into one notification.
|
author, _ := s.users.FindByID(ctx, authorID)
|
||||||
if s.push == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
url := s.frontendURL + "/notifications"
|
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
|
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,
|
Body: body,
|
||||||
URL: url,
|
URL: url,
|
||||||
Icon: s.iconURL(),
|
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
|
// likeMessage renders the aggregated like text: "X liked your post" or
|
||||||
// "X and N others liked your post".
|
// "X and N others liked your post".
|
||||||
func likeMessage(who string, count int) string {
|
func likeMessage(who string, count int) string {
|
||||||
@@ -168,9 +449,13 @@ func likeMessage(who string, count int) string {
|
|||||||
return fmt.Sprintf("%s and %d %s liked your post", who, others, noun)
|
return fmt.Sprintf("%s and %d %s liked your post", who, others, noun)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dispatch persists the notification and sends the matching email. All
|
// dispatch persists the notification and sends it over every channel the
|
||||||
// failures are logged, never propagated.
|
// recipient hasn't opted out of. All failures are logged, never propagated.
|
||||||
func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID, kind, subjectFmt string) {
|
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
|
actor := actorID
|
||||||
if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil {
|
if _, err := s.notifs.Create(ctx, recipientID, &actor, kind); err != nil {
|
||||||
s.logger.Warn("persist notification failed",
|
s.logger.Warn("persist notification failed",
|
||||||
@@ -185,15 +470,21 @@ func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Realtime push (best-effort) — a lightweight nudge so the client can
|
// Realtime push (best-effort) — a lightweight nudge so the client can
|
||||||
// bump the bell + toast immediately. Fires regardless of whether the
|
// bump the bell + toast immediately. Part of the in-app channel.
|
||||||
// recipient has an email; authoritative data still comes from REST.
|
if !prefOff(off, kind, repository.ChannelInApp) {
|
||||||
s.publishRealtime(ctx, recipientID, kind, actorUser)
|
s.publishRealtime(ctx, recipientID, kind, actorUser)
|
||||||
|
}
|
||||||
|
|
||||||
// Web Push (best-effort) — reaches the recipient even with the app
|
// Web Push (best-effort) — reaches the recipient even with the app
|
||||||
// closed. Same seam as realtime/email; nil sender is a no-op.
|
// 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).
|
// Email (best-effort).
|
||||||
|
if prefOff(off, kind, repository.ChannelEmail) {
|
||||||
|
return
|
||||||
|
}
|
||||||
recipient, err := s.users.FindByID(ctx, recipientID)
|
recipient, err := s.users.FindByID(ctx, recipientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err))
|
s.logger.Warn("notification recipient lookup failed", zap.String("recipient", recipientID), zap.Error(err))
|
||||||
@@ -247,20 +538,25 @@ func (s *NotificationService) publishRealtime(ctx context.Context, recipientID,
|
|||||||
// and an email. Best-effort; nothing is persisted to the notification
|
// and an email. Best-effort; nothing is persisted to the notification
|
||||||
// center. `url` is where tapping the notification lands.
|
// center. `url` is where tapping the notification lands.
|
||||||
func (s *NotificationService) SendReminder(ctx context.Context, userID, kind, title, body, url string) {
|
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
|
// Realtime toast — a distinct "reminder" event so the client toasts
|
||||||
// without bumping the unread badge (reminders have no DB row).
|
// without bumping the unread badge (reminders have no DB row).
|
||||||
if s.rt != nil {
|
if s.rt != nil && !prefOff(off, kind, repository.ChannelInApp) {
|
||||||
_ = s.rt.Trigger(ctx, "notifications."+userID, "reminder", map[string]any{
|
_ = s.rt.Trigger(ctx, "notifications."+userID, "reminder", map[string]any{
|
||||||
"kind": kind, "title": title, "body": body, "url": url,
|
"kind": kind, "title": title, "body": body, "url": url,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web Push (reaches closed apps).
|
// 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})
|
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).
|
// Email (only if there's an address on file).
|
||||||
|
if prefOff(off, kind, repository.ChannelEmail) {
|
||||||
|
return
|
||||||
|
}
|
||||||
recipient, err := s.users.FindByID(ctx, userID)
|
recipient, err := s.users.FindByID(ctx, userID)
|
||||||
if err != nil || recipient == nil || recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
|
if err != nil || recipient == nil || recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
|
||||||
return
|
return
|
||||||
@@ -316,6 +612,66 @@ 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.Trigger(ctx, "notifications."+recipientID, "notification", map[string]any{
|
||||||
|
"kind": "admin_message",
|
||||||
|
"body": body,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("realtime admin message publish failed",
|
||||||
|
zap.String("recipient", recipientID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.push != nil {
|
||||||
|
pushTitle := strings.TrimSpace(title)
|
||||||
|
if pushTitle == "" {
|
||||||
|
pushTitle = "Prabhupāda Says"
|
||||||
|
}
|
||||||
|
s.push.SendToUser(ctx, recipientID, PushPayload{
|
||||||
|
Title: pushTitle,
|
||||||
|
Body: body,
|
||||||
|
URL: url,
|
||||||
|
Icon: s.iconURL(),
|
||||||
|
Tag: "admin_message",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email — best-effort, only when the recipient has a verified-or-not address.
|
||||||
|
recipient, _ := s.users.FindByID(ctx, recipientID)
|
||||||
|
if recipient != nil && recipient.Email != nil && strings.TrimSpace(*recipient.Email) != "" {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := adminMessageEmailTpl.Execute(&buf, map[string]any{
|
||||||
|
"RecipientName": recipient.Name,
|
||||||
|
"Title": strings.TrimSpace(title),
|
||||||
|
"Body": body,
|
||||||
|
"URL": url,
|
||||||
|
}); err != nil {
|
||||||
|
s.logger.Warn("render admin message email failed", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
subject := strings.TrimSpace(title)
|
||||||
|
if subject == "" {
|
||||||
|
subject = "A message from Prabhupāda Says"
|
||||||
|
}
|
||||||
|
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), subject, buf.String()); err != nil {
|
||||||
|
s.logger.Warn("admin message email send failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// iconURL is the absolute URL of the notification icon (the app's square
|
// iconURL is the absolute URL of the notification icon (the app's square
|
||||||
// icon), so it renders regardless of the client's base path.
|
// icon), so it renders regardless of the client's base path.
|
||||||
func (s *NotificationService) iconURL() string {
|
func (s *NotificationService) iconURL() string {
|
||||||
@@ -402,3 +758,37 @@ var reminderEmailTpl = template.Must(template.New("reminder").Parse(`<div style=
|
|||||||
<p style="margin-top:1em">Your servants,<br>
|
<p style="margin-top:1em">Your servants,<br>
|
||||||
<strong>The Prabhupāda Says team</strong></p>
|
<strong>The Prabhupāda Says team</strong></p>
|
||||||
</div>`))
|
</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>`))
|
||||||
|
|||||||
@@ -50,33 +50,66 @@ const (
|
|||||||
|
|
||||||
type ReminderService struct {
|
type ReminderService struct {
|
||||||
repo *repository.ReminderRepo
|
repo *repository.ReminderRepo
|
||||||
|
prefs *repository.NotificationPrefsRepo
|
||||||
notif *NotificationService
|
notif *NotificationService
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
sadhnaURL string
|
sadhnaURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReminderService(repo *repository.ReminderRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService {
|
func NewReminderService(repo *repository.ReminderRepo, prefs *repository.NotificationPrefsRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService {
|
||||||
return &ReminderService{
|
return &ReminderService{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
|
prefs: prefs,
|
||||||
notif: notif,
|
notif: notif,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
sadhnaURL: strings.TrimRight(frontendURL, "/") + "/sadhna-streak",
|
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) {
|
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 {
|
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
|
// 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 {
|
func (s *ReminderService) SetTimezone(ctx context.Context, userID, tz string) error {
|
||||||
tz = strings.TrimSpace(tz)
|
tz = canonicalTZ(strings.TrimSpace(tz))
|
||||||
if tz == "" {
|
if tz == "" {
|
||||||
tz = "UTC"
|
tz = "UTC"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ const fallbackTZ = "Asia/Kolkata"
|
|||||||
// genuine UTC user re-reports their real zone on next load.
|
// genuine UTC user re-reports their real zone on next load.
|
||||||
func (s *SadhnaService) resolveTZ(ctx context.Context, userID string) (string, *time.Location) {
|
func (s *SadhnaService) resolveTZ(ctx context.Context, userID string) (string, *time.Location) {
|
||||||
tz, _ := s.logs.UserTimezone(ctx, userID)
|
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" {
|
if tz == "" || tz == "UTC" {
|
||||||
tz = fallbackTZ
|
tz = fallbackTZ
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||||
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
"github.com/prabhupadasays/spsays-bff/internal/repository"
|
||||||
@@ -296,6 +297,23 @@ func (s *SocialGraphService) Relationship(ctx context.Context, viewerID, targetI
|
|||||||
return out, nil
|
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
|
// Counts returns the accepted-follower and accepted-following totals for
|
||||||
// a user.
|
// a user.
|
||||||
func (s *SocialGraphService) Counts(ctx context.Context, userID string) (followers, following int, err error) {
|
func (s *SocialGraphService) Counts(ctx context.Context, userID string) (followers, following int, err error) {
|
||||||
@@ -321,6 +339,13 @@ func (s *SocialGraphService) Followers(ctx context.Context, handle, q string, li
|
|||||||
return s.follows.ListFollowers(ctx, u.ID, q, limit, offset)
|
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) {
|
func (s *SocialGraphService) Following(ctx context.Context, handle, q string, limit, offset int) ([]model.UserSummary, int, error) {
|
||||||
u, err := s.users.FindByHandle(ctx, handle)
|
u, err := s.users.FindByHandle(ctx, handle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -31,14 +31,17 @@ var (
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type PostService struct {
|
type PostService struct {
|
||||||
posts *repository.PostRepo
|
posts *repository.PostRepo
|
||||||
r2 *repository.R2
|
r2 *repository.R2
|
||||||
likes *repository.LikeRepo
|
likes *repository.LikeRepo
|
||||||
notif *NotificationService // optional; set via SetNotifier
|
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 {
|
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}
|
return &PostService{posts: posts, r2: r2, likes: likes, mentions: mentions, comments: comments, feedSeen: feedSeen}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetNotifier wires the notification seam after construction (the
|
// 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
|
// 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
|
// 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.
|
// 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)
|
caption = strings.TrimSpace(caption)
|
||||||
if caption == "" {
|
if caption == "" {
|
||||||
return nil, errors.New("caption is required")
|
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 {
|
if len(imageURLs) > 5 {
|
||||||
return nil, errors.New("a post can hold up to 5 images")
|
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;
|
// 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 {
|
if err := s.likes.Hydrate(ctx, viewerID, []*model.Post{p}); err != nil {
|
||||||
return nil, err
|
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
|
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)
|
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
|
// ReplaceImages swaps the image set on a post. Owner-only — every
|
||||||
// reshare is its own posts row, owned by the resharer, so this works
|
// reshare is its own posts row, owned by the resharer, so this works
|
||||||
// uniformly for canonical posts and reshares.
|
// 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 {
|
if err := s.hydrateLikesSlice(ctx, viewerID, posts); err != nil {
|
||||||
return nil, 0, err
|
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
|
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.
|
// hydrateLikesSlice fills LikesCount + ViewerHasLiked on a value slice.
|
||||||
func (s *PostService) hydrateLikesSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
func (s *PostService) hydrateLikesSlice(ctx context.Context, viewerID string, posts []model.Post) error {
|
||||||
if len(posts) == 0 {
|
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)
|
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
|
// Like / Unlike toggle the caller's like on a post and return the effective
|
||||||
// total (likes_count + pending Redis delta).
|
// total (likes_count + pending Redis delta).
|
||||||
func (s *PostService) Like(ctx context.Context, userID, postID string) (int, error) {
|
func (s *PostService) Like(ctx context.Context, userID, postID string) (int, error) {
|
||||||
@@ -256,6 +435,42 @@ func (s *PostService) ListMine(ctx context.Context, userID string, limit, offset
|
|||||||
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
|
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return posts, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListHidden is the owner's "Hidden" tab — their hidden posts only, hydrated
|
||||||
|
// like ListMine so the cards render fully and the menu offers Unhide.
|
||||||
|
func (s *PostService) ListHidden(ctx context.Context, userID string, limit, offset int) ([]model.Post, int, error) {
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
posts, total, err := s.posts.ListHiddenByUser(ctx, userID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
for i := range posts {
|
||||||
|
if posts[i].ResharedFromPostID != nil {
|
||||||
|
posts[i].ViewerHasReshared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.hydrateLikesSlice(ctx, userID, posts); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := s.hydrateMentionsSlice(ctx, posts); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := s.hydratePinnedSlice(ctx, userID, posts); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
return posts, total, nil
|
return posts, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,6 +690,18 @@ func (s *CollectionService) Get(ctx context.Context, id, viewerID string) (*mode
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
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
|
// PostsIn returns posts without images (single-table boundary). Hydrate
|
||||||
// them in one batched query so callers — and the post cards rendered
|
// them in one batched query so callers — and the post cards rendered
|
||||||
// inside collection carousels — can show the post's first image as
|
// inside collection carousels — can show the post's first image as
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -158,10 +158,11 @@ func normalizeClock(raw string) (string, error) {
|
|||||||
return t, nil
|
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.
|
// 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) {
|
func (s *UserService) Search(ctx context.Context, q string, limit, offset int) ([]model.User, int, error) {
|
||||||
return s.users.Search(ctx, q, limit)
|
return s.users.Search(ctx, q, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetReadingPrefs returns the user's reading prefs blob (opaque JSON).
|
// GetReadingPrefs returns the user's reading prefs blob (opaque JSON).
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS banned_at;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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,78 @@
|
|||||||
|
// Package revalidate pings the frontend's on-demand cache revalidation
|
||||||
|
// endpoint so sitemap-relevant changes (new signups, content re-seeds) surface
|
||||||
|
// within seconds instead of waiting out the frontend's daily ISR window.
|
||||||
|
package revalidate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client posts to <frontend>/api/revalidate. Safe to use when nil or when the
|
||||||
|
// frontend URL / secret are unset — Trigger becomes a no-op (so dev / misconfig
|
||||||
|
// never breaks the calling flow).
|
||||||
|
type Client struct {
|
||||||
|
base string
|
||||||
|
secret string
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(frontendURL, secret string, logger *zap.Logger) *Client {
|
||||||
|
return &Client{
|
||||||
|
base: trimRightSlash(frontendURL),
|
||||||
|
secret: secret,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger fires a fire-and-forget POST to revalidate the given cache tag.
|
||||||
|
// Never blocks the caller and never errors out of band — use from request
|
||||||
|
// handlers (e.g. signup) on a long-lived server.
|
||||||
|
func (c *Client) Trigger(tag string) {
|
||||||
|
if c == nil || c.base == "" || c.secret == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := c.TriggerSync(tag); err != nil && c.logger != nil {
|
||||||
|
c.logger.Warn("revalidate ping failed", zap.String("tag", tag), zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerSync POSTs synchronously and returns any error. Use from short-lived
|
||||||
|
// CLIs (e.g. the indexer) that would exit before a background goroutine ran.
|
||||||
|
// No-op (nil) when the client / URL / secret is unset.
|
||||||
|
func (c *Client) TriggerSync(tag string) error {
|
||||||
|
if c == nil || c.base == "" || c.secret == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
endpoint := fmt.Sprintf("%s/api/revalidate?tag=%s&secret=%s",
|
||||||
|
c.base, url.QueryEscape(tag), url.QueryEscape(c.secret))
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("revalidate returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimRightSlash(s string) string {
|
||||||
|
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user